Compare commits

...

83 Commits

Author SHA1 Message Date
renovate[bot]
25cd24811c Update dependency SharpCompress to 0.49.1 2026-06-11 23:57:25 +00:00
Andi Chandler
dd42a121c4 Translated using Weblate (English (United Kingdom))
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/en_GB/
2026-06-10 22:10:01 +00:00
Bond-009
5ac14f0688 Merge pull request #17058 from nyanmisaka/fix-trickplay-deint
Fix duplicate trickplay images for interlaced videos
2026-06-10 19:20:48 +02:00
Bond-009
d8acea21fb Merge pull request #16444 from dkanada/comic-providers
migrate local comic providers to server codebase
2026-06-10 19:20:20 +02:00
Bond-009
0ef94347c0 Merge pull request #17060 from jellyfin/renovate/dotnet-monorepo
Update dotnet monorepo to v10.0.9
2026-06-10 19:11:02 +02:00
Lofuuzi
5920d8dc3c Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/zh_Hant_HK/
2026-06-10 07:26:21 +00:00
renovate[bot]
25016babc1 Update dotnet monorepo to v10.0.9 2026-06-09 19:23:20 +00:00
Bond-009
eb38f462ad Merge pull request #17054 from nielsvanvelzen/pr-template
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
Add code assistance section to PR template
2026-06-09 21:13:53 +02:00
nyanmisaka
c693047467 Fix duplicate trickplay images for interlaced videos
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-06-09 20:18:17 +08:00
Niels van Velzen
894ee38f68 Add code assistance section to PR template 2026-06-08 21:36:21 +02:00
Bond-009
97fd210cd3 Merge pull request #17042 from Shadowghost/linked-children-local-alternate-priority
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
Prefer local alternate versions when deduplicating linked children
2026-06-08 19:41:39 +02:00
Bond-009
1a786f26c1 Merge pull request #17041 from Shadowghost/media-source-handling-fixes
Media source handling fixes
2026-06-08 19:41:21 +02:00
Bond-009
007515eb73 Merge pull request #10841 from Bond-009/LiveTvImageFix
Refresh live TV channel image when remapped (alt #7843) (fixes #7834)
2026-06-08 19:40:08 +02:00
nenadsuperzmaj
8b002e3fca Translated using Weblate (Serbian)
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/sr/
2026-06-08 12:26:21 +00:00
Thunderstrike116
f5cf68e979 Translated using Weblate (Greek)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/el/
2026-06-08 12:26:21 +00:00
skzeus
f6ce2cedd9 Translated using Weblate (Greek)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/el/
2026-06-08 12:26:21 +00:00
Shadowghost
e71914e993 Keep the queried item's media source as the playback default in MediaSourceManager 2026-06-07 23:06:48 +02:00
Shadowghost
fbe522592a Include owned items when querying items endpoint 2026-06-07 23:06:48 +02:00
Shadowghost
59974490cc Fix MediaSegments for multi versions 2026-06-07 23:06:48 +02:00
Shadowghost
dee63ef3f1 Fix data extraction for alternative versions 2026-06-07 23:06:48 +02:00
Shadowghost
2392e32779 Filter inaccessible alternative versions 2026-06-07 23:06:48 +02:00
Shadowghost
6c931dcdda Keep the queried item's media source as the playback default 2026-06-07 23:06:48 +02:00
Shadowghost
f584759638 Drop linked alternate versions pointing at owned items in migration
(cherry picked from commit 40afd403a6)
2026-06-07 23:06:37 +02:00
Shadowghost
fe1accc0e7 Prefer local alternate versions when deduplicating linked children
(cherry picked from commit 53f02a04ef)
2026-06-07 23:06:37 +02:00
Bond-009
4459147788 Merge pull request #16121 from Shadowghost/search-rebased
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
Implement search providers
2026-06-07 22:56:51 +02:00
Bond-009
003f01a99a Merge pull request #16520 from beateixeira04/PlaylistPrimaryImageFix
fix(dto): prefer PlaylistsFolder primary image for playlists tiles
2026-06-07 22:56:35 +02:00
Bond-009
f56670bdce Merge pull request #17043 from Shadowghost/fix-lockhelper-test-cancellationtoken
Fix xUnit1051 in UserManagerLockHelperTests
2026-06-07 22:51:22 +02:00
Tim Eisele
d8d386e88a Apply suggestions from code review
Co-authored-by: Bond-009 <bond.009@outlook.com>
2026-06-07 22:07:35 +02:00
Shadowghost
b4d970ae38 Fix xUnit1051 in UserManagerLockHelperTests 2026-06-07 22:04:00 +02:00
Bond-009
fa0e1b6e9a Merge pull request #17035 from theguymadmax/update-template-for-10.11.11
Update issue template version to 10.11.11
2026-06-07 20:35:40 +02:00
Bond-009
d873964297 Merge pull request #16945 from Shadowghost/scan-trickplay
Discover existing trickplay files on scan
2026-06-07 20:34:48 +02:00
JPVenson
6ccdaad0a4 Backport pull request #16944 from jellyfin/release-10.11.z
Add lockhelper for UserManager

Original-merge: 39958ad9e5

Merged-by: Bond-009 <bond.009@outlook.com>

Backported-by: Bond_009 <bond.009@outlook.com>
2026-06-07 14:29:36 -04:00
Bond-009
ec43ea156e Merge pull request #16941 from Shadowghost/fix-external-data-pruning
Fix external data pruning on item deletion
2026-06-07 20:28:34 +02:00
Bond-009
cbf284d229 Merge pull request #17028 from Shadowghost/fix-external-sub-invalidation
Fix subtitle replacement not invalidating cache
2026-06-07 20:27:36 +02:00
Shadowghost
8aaea6ea52 Omit external trickplay directory if itme has no path 2026-06-07 14:56:31 +02:00
skzeus
3114c0a9b8 Translated using Weblate (Greek)
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/el/
2026-06-07 11:40:00 +00:00
theguymadmax
c9f9f30baf Update issue template version to 10.11.11 2026-06-07 02:05:22 -04:00
Bond-009
c8da0abf0f Merge pull request #17025 from Shadowghost/fix-playlist-visibility
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
Fix playlist visibility
2026-06-06 14:35:16 +02:00
Shadowghost
a96824f257 Merge remote-tracking branch 'upstream/master' into fix-external-sub-invalidation 2026-06-05 23:41:05 +02:00
Bond-009
1a2db53710 Merge pull request #17024 from jellyfin/renovate/ci-deps
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
Update CI dependencies to v4.36.2
2026-06-05 18:30:23 +02:00
Bond-009
f4db44ffb3 Merge pull request #17019 from Shadowghost/enforce-parental
Enforce parental filtering on additional endpoints
2026-06-05 18:28:55 +02:00
Bond-009
b639166c0a Merge pull request #16998 from Bond-009/revertCodecPreference
Prefer subtitle extension over codec
2026-06-05 18:27:34 +02:00
Shadowghost
efb0336369 Fix subtitle replacement not invalidating cache 2026-06-05 14:14:27 +02:00
Shadowghost
5f13afa1ce Fix playlist visibility 2026-06-04 19:08:48 +02:00
renovate[bot]
45e40d3b33 Update CI dependencies to v4.36.2 2026-06-04 16:58:38 +00:00
Bond-009
53c1c4982a Merge pull request #17018 from jellyfin/Bond-009-patch-1
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
2026-06-04 13:40:58 +02:00
Bond-009
21c0a35edf Merge pull request #16995 from theguymadmax/fix-flat-series
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
Fix season unknown for flat TV structures
2026-06-03 19:48:37 +02:00
Shadowghost
1b80da0c3d Do not set topParentId if OwnerIds are empty 2026-06-03 19:47:35 +02:00
Shadowghost
47f2b3b6d0 Enforce parental filtering on additional endpoints 2026-06-03 19:35:10 +02:00
Bond-009
857b99ce61 Improve Merge Conflict Labeler
Based on the README of the action
https://github.com/eps1lon/actions-label-merge-conflict

Filters based on activity type before starting the action
2026-06-03 19:29:25 +02:00
Bond-009
cf88058099 Merge pull request #17006 from jellyfin/renovate/ci-deps
Update CI dependencies
2026-06-03 19:18:24 +02:00
Bond-009
5ee9e79da2 Merge pull request #16915 from Shadowghost/batch-attachment-extract
Extract attachments in one ffmpeg command when dumping
2026-06-03 18:16:35 +02:00
Bond-009
5ed7798c36 Merge pull request #17007 from nyanmisaka/make-encoder-preset-non-nullable
Make EncoderPreset non nullable
2026-06-03 18:16:21 +02:00
Bond-009
b71b4cc26f Merge pull request #16999 from Shadowghost/fix-recursive
Only default recursive to true if we have includeItemTypes
2026-06-03 18:16:09 +02:00
Bond-009
7185257da5 Merge pull request #16996 from theguymadmax/Fix-movie-capacity
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / Labeling (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
Fix incorrect list capacity
2026-06-02 18:38:02 +02:00
renovate[bot]
d4c962f6e4 Update CI dependencies 2026-06-02 16:34:01 +00:00
Bond-009
52cf8d1ba4 Merge pull request #16994 from theguymadmax/trim-tags
Trim tags
2026-06-02 18:24:08 +02:00
nyanmisaka
081f0ef4a0 Make EncoderPreset non nullable
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-06-02 20:41:30 +08:00
Shadowghost
cc5fb3f1ee Only default recursive to true if we have includeItemTypes 2026-06-01 21:54:49 +02:00
Bond_009
d69de6ccc4 Prefer subtitle extension over codec 2026-06-01 20:57:59 +02:00
theguymadmax
9ab7cc0fe9 Fix incorrect list capacity 2026-06-01 11:20:08 -04:00
theguymadmax
285fc1b9f6 Fix season unknown for flat tv structures 2026-06-01 10:40:52 -04:00
theguymadmax
5ce7170813 Trim tags 2026-05-31 21:13:34 -04:00
Shadowghost
bdb8250300 Fix filename 2026-05-31 20:02:00 +02:00
Shadowghost
a2bab98c23 Merge remote-tracking branch 'upstream/master' into search-rebased 2026-05-31 18:24:26 +02:00
Shadowghost
a479e145dc Merge remote-tracking branch 'upstream/master' into search-rebased
# Conflicts:
#	Emby.Server.Implementations/Library/LibraryManager.cs
#	Jellyfin.Server.Implementations/Item/PeopleRepository.cs
#	MediaBrowser.Controller/Library/ILibraryManager.cs
#	MediaBrowser.Controller/Persistence/IPeopleRepository.cs
2026-05-30 19:09:11 +02:00
Shadowghost
9f350171c6 Discover existing trickplay files on scan 2026-05-27 00:33:31 +02:00
Shadowghost
a05bde53d4 Fix external data pruning on item deletion 2026-05-26 20:44:03 +02:00
Shadowghost
cb9d6e9884 Add batch method for people names 2026-05-24 18:26:21 +02:00
Shadowghost
1175846120 Merge remote-tracking branch 'upstream/master' into search-rebased 2026-05-24 18:25:12 +02:00
Shadowghost
e627c723e2 Extract attachments in one ffmpeg command when dumping 2026-05-23 22:41:44 +02:00
Bond_009
3d8bcf1ffd Alternate solution to #7843 without extra prop 2026-05-21 20:10:21 +02:00
Shadowghost
ea8f6c51fd Merge remote-tracking branch 'upstream/master' into search-rebased 2026-05-16 09:57:06 +02:00
Shadowghost
d71194aa8c Parallelize internal and external calls 2026-05-16 09:50:33 +02:00
Shadowghost
9e794e80c2 Fix master merge 2026-05-12 23:11:34 +02:00
Shadowghost
8f7c54ee5e Merge remote-tracking branch 'upstream/master' into search-rebased 2026-05-12 22:50:16 +02:00
dkanada
65710a4e4f add missing exception information to error log 2026-05-10 12:49:35 +09:00
dkanada
df751af194 fix reported SonarQube issues 2026-05-08 12:51:34 +09:00
Shadowghost
5e82b61bab Apply review suggestions 2026-05-04 23:40:07 +02:00
Shadowghost
ea7000a4d6 Fix Sonar complaints 2026-05-04 02:20:48 +02:00
Shadowghost
07a802d8fa Implement search providers 2026-05-04 01:55:07 +02:00
Beatriz Teixeira
8ceb8c23ce fix(dto): prefer PlaylistsFolder primary image for playlists tiles
This patch fixes issue #16032 where the Playlists media folder ignored a user-uploaded Primary image and kept showing the generated collage. The root cause was DTO image precedence on UserView items for CollectionType.playlists. We now prefer the display parent (PlaylistsFolder) Primary image when available by clearing the UserView Primary tag and setting ParentPrimaryImageItemId/ParentPrimaryImageTag. Added tests cover both paths: parent custom image preferred, and fallback to existing UserView Primary when parent has none.
2026-03-29 15:27:32 +01:00
dkanada
a0d1e05696 migrate local comic providers to server codebase 2026-03-21 12:02:22 +09:00
82 changed files with 3552 additions and 445 deletions

View File

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

View File

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

View File

@@ -1,11 +1,15 @@
<!-- <!--
Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y). Ensure your title is short, descriptive, and in the imperative mood (Fix X, Change Y, instead of Fixed X, Changed Y).
For a good inspiration of what to write in commit messages and PRs please review https://chris.beams.io/posts/git-commit/ and our 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** **Changes**
<!-- Describe your changes here in 1-5 sentences. --> <!-- Describe your changes here in 1-5 sentences. -->
**Code assistance**
<!-- If code assistance was used, describe how it contributed
e.g., code generated by LLM, explanation of code base, debugging guidance. -->
**Issues** **Issues**
<!-- Tag any issues that this PR solves here. <!-- Tag any issues that this PR solves here.
ex. Fixes # --> ex. Fixes # -->

View File

@@ -24,7 +24,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
@@ -32,13 +32,13 @@ jobs:
dotnet-version: '10.0.x' dotnet-version: '10.0.x'
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended queries: +security-extended
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
- name: Perform CodeQL Analysis - 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,7 +11,7 @@ jobs:
permissions: read-all permissions: read-all
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -40,7 +40,7 @@ jobs:
permissions: read-all permissions: read-all
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}

View File

@@ -15,7 +15,7 @@ jobs:
format-check: format-check:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0 - uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
with: with:

View File

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

View File

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

View File

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

View File

@@ -22,7 +22,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with: with:
ref: ${{ inputs.ref }} ref: ${{ inputs.ref }}
repository: ${{ inputs.repository }} repository: ${{ inputs.repository }}

View File

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

View File

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

View File

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

View File

@@ -67,6 +67,7 @@
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" /> <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
<PackageVersion Include="SharpCompress" Version="0.49.1" />
<PackageVersion Include="SharpFuzz" Version="2.2.0" /> <PackageVersion Include="SharpFuzz" Version="2.2.0" />
<PackageVersion Include="SkiaSharp" Version="3.119.4" /> <PackageVersion Include="SkiaSharp" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" /> <PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />

View File

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

View File

@@ -1539,6 +1539,21 @@ namespace Emby.Server.Implementations.Dto
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner) private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
{ {
if (item is UserView { ViewType: CollectionType.playlists } playlistsView
&& options.GetImageLimit(ImageType.Primary) > 0
&& !playlistsView.DisplayParentId.IsEmpty())
{
var displayParent = _libraryManager.GetItemById(playlistsView.DisplayParentId);
var displayParentPrimaryImage = displayParent?.GetImageInfo(ImageType.Primary, 0);
if (displayParentPrimaryImage is not null)
{
dto.ImageTags?.Remove(ImageType.Primary);
dto.ParentPrimaryImageItemId = displayParent!.Id;
dto.ParentPrimaryImageTag = GetTagAndFillBlurhash(dto, displayParent, displayParentPrimaryImage);
}
}
if (!item.SupportsInheritedParentImages) if (!item.SupportsInheritedParentImages)
{ {
return; return;

View File

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

View File

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

View File

@@ -229,7 +229,7 @@ namespace Emby.Server.Implementations.Library
list.Add(source); list.Add(source);
} }
return SortMediaSources(list).ToArray(); return SortMediaSources(list, item.Id).ToArray();
} }
/// <inheritdoc />> /// <inheritdoc />>
@@ -386,6 +386,12 @@ namespace Emby.Server.Implementations.Library
if (user is not null) if (user is not null)
{ {
sources = sources
.Where(source => !Guid.TryParse(source.Id, out var sourceId)
|| sourceId.Equals(item.Id)
|| _libraryManager.GetItemById<BaseItem>(sourceId, user) is not null)
.ToArray();
foreach (var source in sources) foreach (var source in sources)
{ {
SetDefaultAudioAndSubtitleStreamIndices(item, source, user); SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
@@ -540,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 => // The source belonging to the queried item sorts first so it stays the default that gets played.
{ var preferredId = preferredItemId.IsEmpty()
if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile) ? 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; return 1;
}).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0) })
.ThenByDescending(i => .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
{ .ThenByDescending(i =>
var stream = i.VideoStream; {
var stream = i.VideoStream;
return stream?.Width ?? 0; return stream?.Width ?? 0;
}) })
.Where(i => i.Type != MediaSourceType.Placeholder); .Where(i => i.Type != MediaSourceType.Placeholder);
} }
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken) public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -42,6 +43,7 @@ public class ItemsController : BaseJellyfinApiController
private readonly ILogger<ItemsController> _logger; private readonly ILogger<ItemsController> _logger;
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
private readonly IUserDataManager _userDataRepository; private readonly IUserDataManager _userDataRepository;
private readonly ISearchManager _searchManager;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="ItemsController"/> class. /// Initializes a new instance of the <see cref="ItemsController"/> class.
@@ -53,6 +55,7 @@ public class ItemsController : BaseJellyfinApiController
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param>
/// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param> /// <param name="userDataRepository">Instance of the <see cref="IUserDataManager"/> interface.</param>
/// <param name="searchManager">Instance of the <see cref="ISearchManager"/> interface.</param>
public ItemsController( public ItemsController(
IUserManager userManager, IUserManager userManager,
ILibraryManager libraryManager, ILibraryManager libraryManager,
@@ -60,7 +63,8 @@ public class ItemsController : BaseJellyfinApiController
IDtoService dtoService, IDtoService dtoService,
ILogger<ItemsController> logger, ILogger<ItemsController> logger,
ISessionManager sessionManager, ISessionManager sessionManager,
IUserDataManager userDataRepository) IUserDataManager userDataRepository,
ISearchManager searchManager)
{ {
_userManager = userManager; _userManager = userManager;
_libraryManager = libraryManager; _libraryManager = libraryManager;
@@ -69,6 +73,7 @@ public class ItemsController : BaseJellyfinApiController
_logger = logger; _logger = logger;
_sessionManager = sessionManager; _sessionManager = sessionManager;
_userDataRepository = userDataRepository; _userDataRepository = userDataRepository;
_searchManager = searchManager;
} }
/// <summary> /// <summary>
@@ -314,13 +319,10 @@ public class ItemsController : BaseJellyfinApiController
if (collectionType == CollectionType.playlists) if (collectionType == CollectionType.playlists)
{ {
recursive = true; recursive = true;
includeItemTypes = new[] { BaseItemKind.Playlist }; includeItemTypes = [BaseItemKind.Playlist];
} }
else if (folder is ICollectionFolder) 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) if (includeItemTypes.Length == 0)
{ {
includeItemTypes = collectionType switch 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 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) 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) var query = new InternalItemsQuery(user)
{ {
IsPlayed = isPlayed, IsPlayed = isPlayed,
@@ -353,8 +390,8 @@ public class ItemsController : BaseJellyfinApiController
Recursive = recursive ?? false, Recursive = recursive ?? false,
OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder), OrderBy = RequestHelpers.GetOrderBy(sortBy, sortOrder),
IsFavorite = isFavorite, IsFavorite = isFavorite,
Limit = limit, Limit = searchResultScores is null ? limit : null,
StartIndex = startIndex, StartIndex = searchResultScores is null ? startIndex : null,
IsMissing = isMissing, IsMissing = isMissing,
IsUnaired = isUnaired, IsUnaired = isUnaired,
CollapseBoxSetItems = collapseBoxSetItems, CollapseBoxSetItems = collapseBoxSetItems,
@@ -401,7 +438,7 @@ public class ItemsController : BaseJellyfinApiController
ImageTypes = imageTypes, ImageTypes = imageTypes,
VideoTypes = videoTypes, VideoTypes = videoTypes,
AdjacentTo = adjacentTo, AdjacentTo = adjacentTo,
ItemIds = ids, ItemIds = itemIds,
MinCommunityRating = minCommunityRating, MinCommunityRating = minCommunityRating,
MinCriticRating = minCriticRating, MinCriticRating = minCriticRating,
ParentId = parentId ?? Guid.Empty, ParentId = parentId ?? Guid.Empty,
@@ -410,7 +447,7 @@ public class ItemsController : BaseJellyfinApiController
EnableTotalRecordCount = enableTotalRecordCount, EnableTotalRecordCount = enableTotalRecordCount,
ExcludeItemIds = excludeItemIds, ExcludeItemIds = excludeItemIds,
DtoOptions = dtoOptions, DtoOptions = dtoOptions,
SearchTerm = searchTerm, SearchTerm = searchResultScores is null ? searchTerm : null,
MinDateLastSaved = minDateLastSaved?.ToUniversalTime(), MinDateLastSaved = minDateLastSaved?.ToUniversalTime(),
MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(), MinDateLastSavedForUser = minDateLastSavedForUser?.ToUniversalTime(),
MinPremiereDate = minPremiereDate?.ToUniversalTime(), MinPremiereDate = minPremiereDate?.ToUniversalTime(),
@@ -522,7 +559,7 @@ public class ItemsController : BaseJellyfinApiController
{ {
query.AlbumIds = albums.SelectMany(i => 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(); }).ToArray();
} }
@@ -548,12 +585,37 @@ public class ItemsController : BaseJellyfinApiController
// Albums by artist // Albums by artist
if (query.ArtistIds.Length > 0 && query.IncludeItemTypes.Length == 1 && query.IncludeItemTypes[0] == BaseItemKind.MusicAlbum) 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; query.Parent = null;
// folder.GetItems applies user-access filtering via the InternalItemsQuery's User.
result = folder.GetItems(query); result = folder.GetItems(query);
if (searchResultScores is not null && searchResultScores.Count > 0)
{
var orderedItems = result.Items
.OrderByDescending(item => searchResultScores.GetValueOrDefault(item.Id, 0f))
.ThenBy(item => item.SortName)
.ToArray();
var totalCount = orderedItems.Length;
if (startIndex.HasValue && startIndex.Value > 0)
{
orderedItems = orderedItems.Skip(startIndex.Value).ToArray();
}
if (limit.HasValue)
{
orderedItems = orderedItems.Take(limit.Value).ToArray();
}
return new QueryResult<BaseItemDto>(
startIndex,
totalCount,
_dtoService.GetBaseItemDtos(orderedItems, dtoOptions, user));
}
} }
else else
{ {
@@ -909,7 +971,7 @@ public class ItemsController : BaseJellyfinApiController
var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user) var itemsResult = _libraryManager.GetItemsResult(new InternalItemsQuery(user)
{ {
OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) }, OrderBy = [(ItemSortBy.DatePlayed, SortOrder.Descending)],
IsResumable = true, IsResumable = true,
StartIndex = startIndex, StartIndex = startIndex,
Limit = limit, Limit = limit,
@@ -919,6 +981,7 @@ public class ItemsController : BaseJellyfinApiController
MediaTypes = mediaTypes, MediaTypes = mediaTypes,
IsVirtualItem = false, IsVirtualItem = false,
CollapseBoxSetItems = false, CollapseBoxSetItems = false,
IncludeOwnedItems = true,
EnableTotalRecordCount = enableTotalRecordCount, EnableTotalRecordCount = enableTotalRecordCount,
AncestorIds = ancestorIds, AncestorIds = ancestorIds,
IncludeItemTypes = includeItemTypes, IncludeItemTypes = includeItemTypes,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -351,11 +351,20 @@ public class MediaInfoHelper
/// </summary> /// </summary>
/// <param name="result">Playback info response.</param> /// <param name="result">Playback info response.</param>
/// <param name="maxBitrate">Max bitrate.</param> /// <param name="maxBitrate">Max bitrate.</param>
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(); 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 // Nothing beats direct playing a file
if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File) if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)

View File

@@ -953,24 +953,17 @@ public sealed partial class BaseItemRepository
if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0) if (filter.ExcludeProviderIds is not null && filter.ExcludeProviderIds.Count > 0)
{ {
var exclude = filter.ExcludeProviderIds.Select(e => $"{e.Key}:{e.Value}").ToArray(); baseQuery = baseQuery.WhereExcludeProviderIds(filter.ExcludeProviderIds);
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.All(f => !exclude.Contains(f)));
} }
if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0) if (filter.HasAnyProviderId is not null && filter.HasAnyProviderId.Count > 0)
{ {
// Allow setting a null or empty value to get all items that have the specified provider set. baseQuery = baseQuery.WhereHasAnyProviderId(filter.HasAnyProviderId);
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)));
}
var includeSelected = filter.HasAnyProviderId.Where(e => !string.IsNullOrEmpty(e.Value)).Select(e => $"{e.Key}:{e.Value}").ToArray(); if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
if (includeSelected.Length > 0) {
{ baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds);
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeSelected.Contains(f)));
}
} }
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0) if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)

View File

@@ -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 newLinkedChildren = newLinkedChildren
.GroupBy(c => c.ChildId) .GroupBy(c => c.ChildId)
.Select(g => g.Last()) .Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First())
.ToList(); .ToList();
var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList(); var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();

View File

@@ -4,6 +4,7 @@ using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text; using System.Text;
using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AsyncKeyedLock; using AsyncKeyedLock;
@@ -28,7 +29,7 @@ namespace Jellyfin.Server.Implementations.Trickplay;
/// <summary> /// <summary>
/// ITrickplayManager implementation. /// ITrickplayManager implementation.
/// </summary> /// </summary>
public class TrickplayManager : ITrickplayManager public partial class TrickplayManager : ITrickplayManager
{ {
private readonly ILogger<TrickplayManager> _logger; private readonly ILogger<TrickplayManager> _logger;
private readonly IMediaEncoder _mediaEncoder; 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 /> /// <inheritdoc />
public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken) public async Task RefreshTrickplayDataAsync(Video video, bool replace, LibraryOptions libraryOptions, CancellationToken cancellationToken)
{ {
@@ -144,11 +286,27 @@ public class TrickplayManager : ITrickplayManager
return; return;
} }
var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
// Catalog any existing trickplay folders on disk before any prune/generate. This picks up
// user-placed files even when their (width, tile dims) don't match the server's configured values.
if (!replace)
{
await DiscoverExistingTrickplayAsync(video, saveWithMedia, cancellationToken).ConfigureAwait(false);
}
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false)) await using (dbContext.ConfigureAwait(false))
{ {
var saveWithMedia = libraryOptions.SaveTrickplayWithMedia;
var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia); var trickplayDirectory = _pathManager.GetTrickplayDirectory(video, saveWithMedia);
// When extraction is disabled and files live next to media, treat them as user-managed:
// discovery above already catalogued whatever is on disk, leave it alone.
if (!libraryOptions.EnableTrickplayImageExtraction && !replace && saveWithMedia)
{
return;
}
if (!libraryOptions.EnableTrickplayImageExtraction || replace) if (!libraryOptions.EnableTrickplayImageExtraction || replace)
{ {
// Prune existing data // Prune existing data
@@ -688,6 +846,19 @@ public class TrickplayManager : ITrickplayManager
return Path.Combine(path, subdirectory); return Path.Combine(path, subdirectory);
} }
[GeneratedRegex(@"^(\d+) - (\d+)x(\d+)$")]
private static partial Regex TrickplaySubdirRegex();
private static bool HasTrickplayTiles(string directory)
{
if (!Directory.Exists(directory))
{
return false;
}
return new DirectoryInfo(directory).EnumerateFiles("*.jpg").Any();
}
private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width) private async Task<bool> HasTrickplayResolutionAsync(Guid itemId, int width)
{ {
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);

View File

@@ -51,7 +51,7 @@ namespace Jellyfin.Server.Implementations.Users
private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider;
private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly AsyncKeyedLocker<Guid> _userLock = new(); private readonly LockHelper _userLock = new();
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="UserManager"/> class. /// Initializes a new instance of the <see cref="UserManager"/> class.
@@ -214,7 +214,58 @@ namespace Jellyfin.Server.Implementations.Users
{ {
using (await _userLock.LockAsync(user.Id).ConfigureAwait(false)) 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);
}
} }
} }
@@ -453,12 +504,14 @@ namespace Jellyfin.Server.Implementations.Users
var user = GetUserByName(username); var user = GetUserByName(username);
using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false)) using (await _userLock.LockAsync(user?.Id ?? Guid.Empty).ConfigureAwait(false))
{ {
using var dbContext = _dbProvider.CreateDbContext();
// Reload the user now that we hold the lock so the RowVersion is current. // Reload the user now that we hold the lock so the RowVersion is current.
// GetUserByName uses AsNoTracking and the snapshot may be stale if another // GetUserByName uses AsNoTracking and the snapshot may be stale if another
// write (e.g. a concurrent login) incremented RowVersion after our initial load. // write (e.g. a concurrent login) incremented RowVersion after our initial load.
if (user is not null) if (user is not null)
{ {
user = GetUserById(user.Id) ?? user; user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false) ?? user;
} }
var authResult = await AuthenticateLocalUser(username, password, user) var authResult = await AuthenticateLocalUser(username, password, user)
@@ -466,6 +519,13 @@ namespace Jellyfin.Server.Implementations.Users
var authenticationProvider = authResult.AuthenticationProvider; var authenticationProvider = authResult.AuthenticationProvider;
success = authResult.Success; success = authResult.Success;
if (success && user is not null)
{
// refresh the user if the auth provider might have updated it in the auth method.
// this is a hack, this needs removal once the LDAP plugin uses the correct interface to get the user we hand in here and update that one instead.
user = await UserQuery(dbContext).FirstOrDefaultAsync(e => e.Id == user.Id).ConfigureAwait(false);
}
if (user is null) if (user is null)
{ {
string updatedUsername = authResult.Username; string updatedUsername = authResult.Username;
@@ -479,11 +539,16 @@ namespace Jellyfin.Server.Implementations.Users
// Search the database for the user again // Search the database for the user again
// the authentication provider might have created it // the authentication provider might have created it
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) if (authenticationProvider is IHasNewUserPolicy hasNewUserPolicy && user is not null)
{ {
await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false); await UpdatePolicyAsync(user.Id, hasNewUserPolicy.GetNewUserPolicy()).ConfigureAwait(false);
user = await UserQuery(dbContext)
.FirstOrDefaultAsync(e => e.NormalizedUsername == username.ToUpperInvariant()).ConfigureAwait(false);
#pragma warning restore CA1862 // Use the 'StringComparison' method overloads to perform case-insensitive string comparisons
} }
} }
} }
@@ -494,8 +559,10 @@ namespace Jellyfin.Server.Implementations.Users
if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase)) if (providerId is not null && !string.Equals(providerId, user.AuthenticationProviderId, StringComparison.OrdinalIgnoreCase))
{ {
user.AuthenticationProviderId = providerId; await dbContext.Users
await UpdateUserInternalAsync(user).ConfigureAwait(false); .Where(e => e.Id == user.Id)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.AuthenticationProviderId, providerId))
.ConfigureAwait(false);
} }
} }
@@ -542,16 +609,42 @@ namespace Jellyfin.Server.Implementations.Users
{ {
if (isUserSession) 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 dbContext.Users
await UpdateUserInternalAsync(user).ConfigureAwait(false); .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); _logger.LogInformation("Authentication request for {UserName} has succeeded.", user.Username);
} }
else 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( _logger.LogInformation(
"Authentication request for {UserName} has been denied (IP: {IP}).", "Authentication request for {UserName} has been denied (IP: {IP}).",
user.Username, user.Username,
@@ -926,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) private async Task UpdateUserInternalAsync(JellyfinDbContext dbContext, User user)
{ {
dbContext.Users.Attach(user); dbContext.Users.Attach(user);
@@ -977,5 +1044,70 @@ namespace Jellyfin.Server.Implementations.Users
_userLock.Dispose(); _userLock.Dispose();
} }
} }
internal sealed class LockHelper : IDisposable
{
private readonly AsyncKeyedLocker<Guid> _userLock = new();
private bool _disposed;
public static AsyncLocal<int> IsNestedLock { get; set; } = new();
public bool ShouldLock()
{
return IsNestedLock.Value == 0;
}
public ValueTask<IDisposable> LockAsync(Guid key)
{
ThrowIfDisposed();
var isNested = LockHelper.IsNestedLock.Value != 0;
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value + 1;
if (isNested)
{
return new ValueTask<IDisposable>(new LockHandle { Parent = null });
}
return AcquireLockAsync(key);
}
private async ValueTask<IDisposable> AcquireLockAsync(Guid key)
{
var lockHandle = await _userLock.LockAsync(key, true).ConfigureAwait(false);
return new LockHandle { Parent = lockHandle };
}
public void Dispose()
{
if (_disposed)
{
return;
}
_disposed = true;
_userLock.Dispose();
}
private void ThrowIfDisposed()
{
ObjectDisposedException.ThrowIf(_disposed, this);
}
private sealed class LockHandle : IDisposable
{
public required IDisposable? Parent { get; init; }
public void Dispose()
{
Parent?.Dispose();
LockHelper.IsNestedLock.Value = LockHelper.IsNestedLock.Value - 1;
if (LockHelper.IsNestedLock.Value < 0)
{
throw new InvalidOperationException("Mismatched locking detected. Threads internal NestedLock is less then 0 which should not be possible.");
}
}
}
}
} }
} }

View File

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

View File

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

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

View File

@@ -906,7 +906,10 @@ namespace MediaBrowser.Controller.Entities
query.Parent = this; 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); return QueryWithPostFiltering(query);
} }
@@ -927,7 +930,7 @@ namespace MediaBrowser.Controller.Entities
if (user is not null) if (user is not null)
{ {
// needed for boxsets // needed for boxsets and playlists
itemsList = itemsList.Where(i => i.IsVisibleStandalone(query.User)); itemsList = itemsList.Where(i => i.IsVisibleStandalone(query.User));
} }

View File

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

View File

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

View File

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

View File

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

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

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

@@ -444,6 +444,13 @@ namespace MediaBrowser.Controller.MediaEncoding
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG); || state.VideoStream.VideoRangeType == VideoRangeType.HLG);
} }
private static bool IsDeinterlaceAvailable(EncodingJobInfo state)
{
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
return doDeintH264 || doDeintHevc;
}
private bool IsVideoStreamHevcRext(EncodingJobInfo state) private bool IsVideoStreamHevcRext(EncodingJobInfo state)
{ {
var videoStream = state.VideoStream; var videoStream = state.VideoStream;
@@ -3850,9 +3857,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase);
var isV4l2Encoder = vidEncoder.Contains("h264_v4l2m2m", StringComparison.OrdinalIgnoreCase); var isV4l2Encoder = vidEncoder.Contains("h264_v4l2m2m", StringComparison.OrdinalIgnoreCase);
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var doDeintH2645 = IsDeinterlaceAvailable(state);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doToneMap = IsSwTonemapAvailable(state, options); var doToneMap = IsSwTonemapAvailable(state, options);
var requireDoviReshaping = doToneMap && state.VideoStream.VideoRangeType == VideoRangeType.DOVI; var requireDoviReshaping = doToneMap && state.VideoStream.VideoRangeType == VideoRangeType.DOVI;
@@ -4004,9 +4009,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isCuInCuOut = isNvDecoder && isNvencEncoder; var isCuInCuOut = isNvDecoder && isNvencEncoder;
var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30; var doubleRateDeint = options.DeinterlaceDoubleRate && (state.VideoStream?.ReferenceFrameRate ?? 60) <= 30;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var doDeintH2645 = IsDeinterlaceAvailable(state);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doCuTonemap = IsHwTonemapAvailable(state, options); var doCuTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -4215,9 +4218,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder; var isDxInDxOut = isD3d11vaDecoder && isAmfEncoder;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var doDeintH2645 = IsDeinterlaceAvailable(state);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doOclTonemap = IsHwTonemapAvailable(state, options); var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -4463,9 +4464,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isQsvInQsvOut = isHwDecoder && isQsvEncoder; var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var doDeintH2645 = IsDeinterlaceAvailable(state);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doVppTonemap = IsIntelVppTonemapAvailable(state, options); var doVppTonemap = IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVppTonemap && IsHwTonemapAvailable(state, options); var doOclTonemap = !doVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVppTonemap || doOclTonemap; var doTonemap = doVppTonemap || doOclTonemap;
@@ -4757,12 +4756,10 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isQsvInQsvOut = isHwDecoder && isQsvEncoder; var isQsvInQsvOut = isHwDecoder && isQsvEncoder;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVaVppTonemap = IsIntelVppTonemapAvailable(state, options); var doVaVppTonemap = IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options); var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVaVppTonemap || doOclTonemap; var doTonemap = doVaVppTonemap || doOclTonemap;
var doDeintH2645 = doDeintH264 || doDeintHevc; var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5088,12 +5085,10 @@ namespace MediaBrowser.Controller.MediaEncoding
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var isVaInVaOut = isVaapiDecoder && isVaapiEncoder; var isVaInVaOut = isVaapiDecoder && isVaapiEncoder;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVaVppTonemap = isVaapiDecoder && IsIntelVppTonemapAvailable(state, options); var doVaVppTonemap = isVaapiDecoder && IsIntelVppTonemapAvailable(state, options);
var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options); var doOclTonemap = !doVaVppTonemap && IsHwTonemapAvailable(state, options);
var doTonemap = doVaVppTonemap || doOclTonemap; var doTonemap = doVaVppTonemap || doOclTonemap;
var doDeintH2645 = doDeintH264 || doDeintHevc; var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5325,10 +5320,8 @@ namespace MediaBrowser.Controller.MediaEncoding
var isSwEncoder = !isVaapiEncoder; var isSwEncoder = !isVaapiEncoder;
var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase); var isMjpegEncoder = vidEncoder.Contains("mjpeg", StringComparison.OrdinalIgnoreCase);
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doVkTonemap = IsVulkanHwTonemapAvailable(state, options); var doVkTonemap = IsVulkanHwTonemapAvailable(state, options);
var doDeintH2645 = doDeintH264 || doDeintHevc; var doDeintH2645 = IsDeinterlaceAvailable(state);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream;
@@ -5565,9 +5558,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965; var isi965Driver = _mediaEncoder.IsVaapiDeviceInteli965;
var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd; var isAmdDriver = _mediaEncoder.IsVaapiDeviceAmd;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var doDeintH2645 = IsDeinterlaceAvailable(state);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doOclTonemap = IsHwTonemapAvailable(state, options); var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -5798,9 +5789,7 @@ namespace MediaBrowser.Controller.MediaEncoding
var reqMaxH = state.BaseRequest.MaxHeight; var reqMaxH = state.BaseRequest.MaxHeight;
var threeDFormat = state.MediaSource.Video3DFormat; var threeDFormat = state.MediaSource.Video3DFormat;
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var doDeintH2645 = IsDeinterlaceAvailable(state);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doVtTonemap = IsVideoToolboxTonemapAvailable(state, options); var doVtTonemap = IsVideoToolboxTonemapAvailable(state, options);
var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options); var doMetalTonemap = !doVtTonemap && IsHwTonemapAvailable(state, options);
var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface); var usingHwSurface = isVtDecoder && (_mediaEncoder.EncoderVersion >= _minFFmpegWorkingVtHwSurface);
@@ -5999,9 +5988,7 @@ namespace MediaBrowser.Controller.MediaEncoding
&& (vidEncoder.Contains("h264", StringComparison.OrdinalIgnoreCase) && (vidEncoder.Contains("h264", StringComparison.OrdinalIgnoreCase)
|| vidEncoder.Contains("hevc", StringComparison.OrdinalIgnoreCase)); || vidEncoder.Contains("hevc", StringComparison.OrdinalIgnoreCase));
var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); var doDeintH2645 = IsDeinterlaceAvailable(state);
var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true);
var doDeintH2645 = doDeintH264 || doDeintHevc;
var doOclTonemap = IsHwTonemapAvailable(state, options); var doOclTonemap = IsHwTonemapAvailable(state, options);
var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state); var hasSubs = state.SubtitleStream is not null && ShouldEncodeSubtitle(state);
@@ -6265,12 +6252,21 @@ namespace MediaBrowser.Controller.MediaEncoding
overlayFilters?.RemoveAll(string.IsNullOrEmpty); overlayFilters?.RemoveAll(string.IsNullOrEmpty);
var framerate = GetFramerateParam(state); var framerate = GetFramerateParam(state);
if (framerate.HasValue) if (mainFilters is not null && framerate.HasValue)
{ {
mainFilters.Insert(0, string.Format( var doDeintH2645 = IsDeinterlaceAvailable(state);
CultureInfo.InvariantCulture, var fpsFilter = string.Format(CultureInfo.InvariantCulture, "fps={0}", framerate.Value);
"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; var mainStr = string.Empty;

View File

@@ -1,8 +1,10 @@
using System; using System;
using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AsyncKeyedLock; using AsyncKeyedLock;
@@ -102,13 +104,10 @@ namespace MediaBrowser.MediaEncoding.Attachments
&& (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase))); && (a.FileName.Contains('/', StringComparison.OrdinalIgnoreCase) || a.FileName.Contains('\\', StringComparison.OrdinalIgnoreCase)));
if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase)) if (shouldExtractOneByOne && !inputFile.EndsWith(".mks", StringComparison.OrdinalIgnoreCase))
{ {
foreach (var attachment in mediaSource.MediaAttachments) await ExtractAllAttachmentsIndividuallyInternal(
{ inputFile,
if (!string.Equals(attachment.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase)) mediaSource,
{ cancellationToken).ConfigureAwait(false);
await ExtractAttachment(inputFile, mediaSource, attachment, cancellationToken).ConfigureAwait(false);
}
}
} }
else 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( private async Task ExtractAllAttachmentsInternal(
string inputFile, string inputFile,
MediaSourceInfo mediaSource, MediaSourceInfo mediaSource,

View File

@@ -214,7 +214,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}; };
} }
var currentFormat = subtitleStream.Codec ?? Path.GetExtension(subtitleStream.Path).TrimStart('.'); // Normalize ffmpeg codec names to the file extensions the parser is keyed on
var currentFormat = NormalizeCodecToParserExtension((Path.GetExtension(subtitleStream.Path) ?? subtitleStream.Codec).TrimStart('.'));
// Handle PGS subtitles as raw streams for the client to render // Handle PGS subtitles as raw streams for the client to render
if (MediaStream.IsPgsFormat(currentFormat)) if (MediaStream.IsPgsFormat(currentFormat))
@@ -324,13 +325,91 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{ {
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
{ {
if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0) if (!IsCachedSubtitleFresh(outputPath, subtitleStream.Path))
{ {
await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false); await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
} }
} }
} }
// ffmpeg codec names don't always match the file extensions the subtitle parser is keyed on.
private static string NormalizeCodecToParserExtension(string codecOrExtension)
{
return codecOrExtension switch
{
"subrip" => "srt",
"webvtt" => "vtt",
_ => codecOrExtension
};
}
// Records "this cache was built from this exact source revision" in a sidecar file next to the cache: "<sizeBytes>:<mtimeTicks>"
private static string GetCacheMetaPath(string cachePath) => cachePath + ".meta";
private static string FormatCacheMeta(long length, DateTime lastWriteUtc)
=> string.Create(CultureInfo.InvariantCulture, $"{length}:{lastWriteUtc.Ticks}");
private bool IsCachedSubtitleFresh(string cachePath, string? sourcePath)
{
if (!File.Exists(cachePath))
{
return false;
}
var cacheInfo = _fileSystem.GetFileInfo(cachePath);
if (cacheInfo.Length == 0)
{
return false;
}
if (string.IsNullOrEmpty(sourcePath) || !File.Exists(sourcePath))
{
return true;
}
var metaPath = GetCacheMetaPath(cachePath);
if (!File.Exists(metaPath))
{
// Pre-existing cache from before metadata tracking - regenerate so we can record the source state.
return false;
}
try
{
var sourceInfo = _fileSystem.GetFileInfo(sourcePath);
var expected = FormatCacheMeta(sourceInfo.Length, sourceInfo.LastWriteTimeUtc);
var actual = File.ReadAllText(metaPath);
return string.Equals(expected, actual, StringComparison.Ordinal);
}
catch (IOException)
{
return false;
}
}
private void WriteCacheMeta(string cachePath, string? sourcePath)
{
if (string.IsNullOrEmpty(sourcePath))
{
return;
}
try
{
var sourceInfo = _fileSystem.GetFileInfo(sourcePath);
if (!sourceInfo.Exists)
{
return;
}
File.WriteAllText(GetCacheMetaPath(cachePath), FormatCacheMeta(sourceInfo.Length, sourceInfo.LastWriteTimeUtc));
}
catch (IOException ex)
{
_logger.LogWarning(ex, "Failed to record subtitle cache metadata for {CachePath}", cachePath);
}
}
/// <summary> /// <summary>
/// Converts the text subtitle to SRT internal. /// Converts the text subtitle to SRT internal.
/// </summary> /// </summary>
@@ -375,7 +454,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
CreateNoWindow = true, CreateNoWindow = true,
UseShellExecute = false, UseShellExecute = false,
FileName = _mediaEncoder.EncoderPath, FileName = _mediaEncoder.EncoderPath,
Arguments = string.Format(CultureInfo.InvariantCulture, "{0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath), Arguments = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),
WindowStyle = ProcessWindowStyle.Hidden, WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false ErrorDialog = false
}, },
@@ -455,6 +534,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false); await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
WriteCacheMeta(outputPath, inputPath);
_logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath); _logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
} }
@@ -531,7 +612,8 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false); var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
if (File.Exists(outputPath) && _fileSystem.GetFileInfo(outputPath).Length > 0) var sourcePath = string.IsNullOrEmpty(subtitleStream.Path) ? mediaSource.Path : subtitleStream.Path;
if (IsCachedSubtitleFresh(outputPath, sourcePath))
{ {
releaser.Dispose(); releaser.Dispose();
continue; continue;
@@ -588,7 +670,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputPaths = new List<string>(); var outputPaths = new List<string>();
var args = string.Format( var args = string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
"-i {0}", "-y -i {0}",
inputPath); inputPath);
foreach (var subtitleStream in subtitleStreams) foreach (var subtitleStream in subtitleStreams)
@@ -628,6 +710,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
} }
await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
foreach (var outputPath in outputPaths)
{
WriteCacheMeta(outputPath, mksFile);
}
} }
} }
@@ -683,6 +770,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (outputPaths.Count > 0) if (outputPaths.Count > 0)
{ {
await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false); await ExtractSubtitlesForFile(inputPath, args, outputPaths, cancellationToken).ConfigureAwait(false);
foreach (var outputPath in outputPaths)
{
WriteCacheMeta(outputPath, mediaSource.Path);
}
} }
} }

View File

@@ -43,6 +43,7 @@ public class EncodingOptions
VppTonemappingContrast = 1; VppTonemappingContrast = 1;
H264Crf = 23; H264Crf = 23;
H265Crf = 28; H265Crf = 28;
EncoderPreset = EncoderPreset.auto;
DeinterlaceDoubleRate = false; DeinterlaceDoubleRate = false;
DeinterlaceMethod = DeinterlaceMethod.yadif; DeinterlaceMethod = DeinterlaceMethod.yadif;
EnableDecodingColorDepth10Hevc = true; EnableDecodingColorDepth10Hevc = true;
@@ -217,7 +218,7 @@ public class EncodingOptions
/// <summary> /// <summary>
/// Gets or sets the encoder preset. /// Gets or sets the encoder preset.
/// </summary> /// </summary>
public EncoderPreset? EncoderPreset { get; set; } public EncoderPreset EncoderPreset { get; set; }
/// <summary> /// <summary>
/// Gets or sets a value indicating whether the framerate is doubled when deinterlacing. /// Gets or sets a value indicating whether the framerate is doubled when deinterlacing.

View File

@@ -17,6 +17,7 @@ namespace MediaBrowser.Model.Configuration
LyricFetcher, LyricFetcher,
MediaSegmentProvider, MediaSegmentProvider,
LocalSimilarityProvider, LocalSimilarityProvider,
SimilarityProvider SimilarityProvider,
SearchProvider
} }
} }

View File

@@ -0,0 +1,246 @@
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions.Json;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using MediaBrowser.Providers.Books.ComicBookInfo.Models;
using Microsoft.Extensions.Logging;
using SharpCompress.Archives.Zip;
namespace MediaBrowser.Providers.Books.ComicBookInfo;
/// <summary>
/// ComicBookInfo provider.
/// </summary>
public class ComicBookInfoProvider : IComicProvider
{
private readonly ILogger<ComicBookInfoProvider> _logger;
private readonly IFileSystem _fileSystem;
/// <summary>
/// Initializes a new instance of the <see cref="ComicBookInfoProvider"/> class.
/// </summary>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{ComicBookInfoProvider}"/> interface.</param>
public ComicBookInfoProvider(IFileSystem fileSystem, ILogger<ComicBookInfoProvider> logger)
{
_fileSystem = fileSystem;
_logger = logger;
}
/// <inheritdoc />
public async ValueTask<MetadataResult<Book>> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
{
var path = GetComicBookFile(info.Path)?.FullName;
if (path is null)
{
_logger.LogError("could not load comic: {Path}", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
try
{
Stream stream = File.OpenRead(path);
// not yet async: https://github.com/adamhathcock/sharpcompress/pull/565
await using (stream.ConfigureAwait(false))
using (var archive = ZipArchive.Open(stream))
{
if (!archive.IsComplete)
{
_logger.LogError("incomplete comic archive: {Path}", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
var volume = archive.Volumes.First();
if (volume.Comment is null)
{
_logger.LogInformation("missing ComicBookInfo in archive comment: {Path}", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
var comicBookMetadata = JsonSerializer.Deserialize<ComicBookInfoFormat>(volume.Comment, JsonDefaults.Options);
if (comicBookMetadata is null)
{
_logger.LogError("ComicBookInfo deserialization failure: {Path}", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
return SaveMetadata(comicBookMetadata);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "failed to load ComicBookInfo metadata: {Path}", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
}
/// <inheritdoc />
public bool HasItemChanged(BaseItem item)
{
var file = GetComicBookFile(item.Path);
if (file is null)
{
return false;
}
return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
}
private MetadataResult<Book> SaveMetadata(ComicBookInfoFormat comic)
{
if (comic.Metadata is null)
{
return new MetadataResult<Book> { HasMetadata = false };
}
var book = ReadComicBookMetadata(comic.Metadata);
if (book is null)
{
return new MetadataResult<Book> { HasMetadata = false };
}
var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
if (comic.Metadata.Language is not null)
{
metadataResult.ResultLanguage = ReadCultureInfoInto(comic.Metadata.Language);
}
if (comic.Metadata.Credits.Count > 0)
{
ReadPeopleMetadata(comic.Metadata, metadataResult);
}
return metadataResult;
}
private FileSystemMetadata? GetComicBookFile(string path)
{
var fileInfo = _fileSystem.GetFileSystemInfo(path);
if (fileInfo.IsDirectory)
{
return null;
}
// only parse files that are known to have ComicBookInfo metadata
return fileInfo.Extension.Equals(".cbz", StringComparison.OrdinalIgnoreCase) ? fileInfo : null;
}
private static Book? ReadComicBookMetadata(ComicBookInfoMetadata comic)
{
var book = new Book();
var hasFoundMetadata = false;
hasFoundMetadata |= ReadStringInto(comic.Title, title => book.Name = title);
hasFoundMetadata |= ReadStringInto(comic.Series, series => book.SeriesName = series);
hasFoundMetadata |= ReadStringInto(comic.Genre, genre => book.AddGenre(genre));
hasFoundMetadata |= ReadStringInto(comic.Comments, overview => book.Overview = overview);
hasFoundMetadata |= ReadStringInto(comic.Publisher, publisher => book.SetStudios([publisher]));
if (comic.PublicationYear is not null)
{
book.ProductionYear = comic.PublicationYear;
hasFoundMetadata = true;
}
if (comic.Issue is not null)
{
book.IndexNumber = comic.Issue;
hasFoundMetadata = true;
}
if (comic.Tags.Count > 0)
{
book.Tags = comic.Tags.ToArray();
hasFoundMetadata = true;
}
if (comic.PublicationYear is not null && comic.PublicationMonth is not null)
{
book.PremiereDate = ReadTwoPartDateInto(comic.PublicationYear.Value, comic.PublicationMonth.Value);
hasFoundMetadata = true;
}
return hasFoundMetadata ? book : null;
}
private static void ReadPeopleMetadata(ComicBookInfoMetadata comic, MetadataResult<Book> metadataResult)
{
foreach (var person in comic.Credits)
{
if (person.Person is null || person.Role is null)
{
continue;
}
if (person.Person.Contains(',', StringComparison.InvariantCultureIgnoreCase))
{
var name = person.Person.Split(',');
person.Person = name[1].Trim(' ') + " " + name[0].Trim(' ');
}
if (!Enum.TryParse(person.Role, out PersonKind personKind))
{
personKind = PersonKind.Unknown;
}
if (string.Equals("Colorer", person.Role, StringComparison.OrdinalIgnoreCase))
{
personKind = PersonKind.Colorist;
}
metadataResult.AddPerson(new PersonInfo { Name = person.Person, Type = personKind });
}
}
private static string? ReadCultureInfoInto(string language)
{
try
{
return CultureInfo.GetCultureInfo(language).DisplayName;
}
catch (CultureNotFoundException)
{
return null;
}
}
private static bool ReadStringInto(string? data, Action<string> commitResult)
{
if (!string.IsNullOrWhiteSpace(data))
{
commitResult(data);
return true;
}
return false;
}
private static DateTime? ReadTwoPartDateInto(int year, int month)
{
try
{
// use first day of the month because this format doesn't include a day
return new DateTime(year, month, 1, 0, 0, 0, DateTimeKind.Unspecified);
}
catch (ArgumentOutOfRangeException)
{
return null;
}
}
}

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace MediaBrowser.Providers.Books.ComicBookInfo.Models;
/// <summary>
/// ComicBookInfo credit.
/// </summary>
public class ComicBookInfoCredit
{
/// <summary>
/// Gets or sets the person name.
/// </summary>
[JsonPropertyName("person")]
public string? Person { get; set; }
/// <summary>
/// Gets or sets the role.
/// </summary>
[JsonPropertyName("role")]
public string? Role { get; set; }
}

View File

@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace MediaBrowser.Providers.Books.ComicBookInfo.Models;
/// <summary>
/// ComicBookInfo format.
/// </summary>
public class ComicBookInfoFormat
{
/// <summary>
/// Gets or sets the app ID.
/// </summary>
[JsonPropertyName("appID")]
public string? AppId { get; set; }
/// <summary>
/// Gets or sets the last modified timestamp.
/// </summary>
[JsonPropertyName("lastModified")]
public string? LastModified { get; set; }
/// <summary>
/// Gets or sets the metadata.
/// </summary>
[JsonPropertyName("ComicBookInfo/1.0")]
public ComicBookInfoMetadata? Metadata { get; set; }
}

View File

@@ -0,0 +1,107 @@
using System;
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace MediaBrowser.Providers.Books.ComicBookInfo.Models;
/// <summary>
/// ComicBookInfo metadata.
/// </summary>
public class ComicBookInfoMetadata
{
/// <summary>
/// Gets or sets the series.
/// </summary>
[JsonPropertyName("series")]
public string? Series { get; set; }
/// <summary>
/// Gets or sets the title.
/// </summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>
/// Gets or sets the publisher.
/// </summary>
[JsonPropertyName("publisher")]
public string? Publisher { get; set; }
/// <summary>
/// Gets or sets the publication month.
/// </summary>
[JsonPropertyName("publicationMonth")]
public int? PublicationMonth { get; set; }
/// <summary>
/// Gets or sets the publication year.
/// </summary>
[JsonPropertyName("publicationYear")]
public int? PublicationYear { get; set; }
/// <summary>
/// Gets or sets the issue number.
/// </summary>
[JsonPropertyName("issue")]
public int? Issue { get; set; }
/// <summary>
/// Gets or sets the number of issues.
/// </summary>
[JsonPropertyName("numberOfIssues")]
public int? NumberOfIssues { get; set; }
/// <summary>
/// Gets or sets the volume number.
/// </summary>
[JsonPropertyName("volume")]
public int? Volume { get; set; }
/// <summary>
/// Gets or sets the number of volumes.
/// </summary>
[JsonPropertyName("numberOfVolumes")]
public int? NumberOfVolumes { get; set; }
/// <summary>
/// Gets or sets the rating.
/// </summary>
[JsonPropertyName("rating")]
public int? Rating { get; set; }
/// <summary>
/// Gets or sets the genre.
/// </summary>
[JsonPropertyName("genre")]
public string? Genre { get; set; }
/// <summary>
/// Gets or sets the language.
/// </summary>
[JsonPropertyName("language")]
public string? Language { get; set; }
/// <summary>
/// Gets or sets the country.
/// </summary>
[JsonPropertyName("country")]
public string? Country { get; set; }
/// <summary>
/// Gets or sets the list of credits.
/// </summary>
[JsonPropertyName("credits")]
public IReadOnlyList<ComicBookInfoCredit> Credits { get; set; } = Array.Empty<ComicBookInfoCredit>();
/// <summary>
/// Gets or sets the list of tags.
/// </summary>
[JsonPropertyName("tags")]
public IReadOnlyList<string> Tags { get; set; } = Array.Empty<string>();
/// <summary>
/// Gets or sets the comments.
/// </summary>
[JsonPropertyName("comments")]
public string? Comments { get; set; }
}

View File

@@ -0,0 +1,146 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
using SharpCompress.Archives;
namespace MediaBrowser.Providers.Books;
/// <summary>
/// The ComicImageProvider tries to find either an image named "cover" or, in case that
/// fails, just takes the first image inside the archive, hoping that it is the cover.
/// </summary>
public class ComicImageProvider : IDynamicImageProvider
{
private readonly string[] _comicBookExtensions = [".cb7", ".cbr", ".cbt", ".cbz"];
private readonly string[] _coverExtensions = [".png", ".jpeg", ".jpg", ".webp", ".bmp", ".gif"];
private readonly ILogger<ComicImageProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ComicImageProvider"/> class.
/// </summary>
/// <param name="logger">Instance of the <see cref="ILogger{ComicImageProvider}"/> interface.</param>
public ComicImageProvider(ILogger<ComicImageProvider> logger)
{
_logger = logger;
}
/// <inheritdoc />
public string Name => "Comic Book Archive Cover Extractor";
/// <inheritdoc />
public Task<DynamicImageResponse> GetImage(BaseItem item, ImageType type, CancellationToken cancellationToken)
{
var extension = Path.GetExtension(item.Path);
if (_comicBookExtensions.Contains(extension, StringComparer.OrdinalIgnoreCase))
{
return LoadCover(item);
}
return Task.FromResult(new DynamicImageResponse { HasImage = false });
}
/// <inheritdoc />
public IEnumerable<ImageType> GetSupportedImages(BaseItem item)
{
yield return ImageType.Primary;
}
/// <inheritdoc />
public bool Supports(BaseItem item)
{
return item is Book;
}
/// <summary>
/// Tries to load a cover from the CBZ archive. Returns a response
/// with no image if nothing is found.
/// </summary>
/// <param name="item">Item to check for covers.</param>
private async Task<DynamicImageResponse> LoadCover(BaseItem item)
{
var memoryStream = new MemoryStream();
try
{
ImageFormat imageFormat;
using (Stream stream = File.OpenRead(item.Path))
using (var archive = ArchiveFactory.Open(stream))
{
// throw exception to log results if no cover is found
(var cover, imageFormat) = FindCoverEntryInArchive(archive) ?? throw new InvalidOperationException("no supported cover found");
// copy the cover to memory stream
await cover.OpenEntryStream().CopyToAsync(memoryStream).ConfigureAwait(false);
}
// reset stream position after copying
memoryStream.Position = 0;
return new DynamicImageResponse { HasImage = true, Stream = memoryStream, Format = imageFormat };
}
catch (Exception e)
{
_logger.LogError(e, "failed to load cover from {Path}", item.Path);
return new DynamicImageResponse { HasImage = false };
}
}
/// <summary>
/// Tries to find the entry containing the cover.
/// </summary>
/// <param name="archive">The archive to search.</param>
/// <returns>The search result.</returns>
private (IArchiveEntry CoverEntry, ImageFormat ImageFormat)? FindCoverEntryInArchive(IArchive archive)
{
IArchiveEntry? cover;
// only some comics will explicitly name their cover file
// in many cases the cover will simply be the first image in the archive
foreach (var extension in _coverExtensions)
{
cover = archive.Entries.FirstOrDefault(e => e.Key == "cover" + extension);
if (cover is not null)
{
var imageFormat = GetImageFormat(extension);
return (cover, imageFormat);
}
}
cover = archive.Entries.OrderBy(x => x.Key).FirstOrDefault(x => _coverExtensions.Contains(Path.GetExtension(x.Key), StringComparison.OrdinalIgnoreCase));
if (cover is not null)
{
var imageFormat = GetImageFormat(Path.GetExtension(cover.Key ?? string.Empty));
return (cover, imageFormat);
}
return null;
}
private static ImageFormat GetImageFormat(string extension) => extension.ToLowerInvariant() switch
{
".jpg" => ImageFormat.Jpg,
".jpeg" => ImageFormat.Jpg,
".png" => ImageFormat.Png,
".webp" => ImageFormat.Webp,
".bmp" => ImageFormat.Bmp,
".gif" => ImageFormat.Gif,
".svg" => ImageFormat.Svg,
_ => throw new ArgumentException($"unsupported extension: {extension}"),
};
}

View File

@@ -0,0 +1,212 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Xml.Linq;
using System.Xml.XPath;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using SharpCompress;
namespace MediaBrowser.Providers.Books.ComicInfo;
/// <summary>
/// ComicInfo reader.
/// </summary>
public static class ComicInfoReader
{
/// <summary>
/// Filename to check for comic metadata either next to the comic file or inside the archive.
/// </summary>
public const string ComicRackMetaFile = "ComicInfo.xml";
/// <summary>
/// Read comic book metadata.
/// </summary>
/// <param name="xml">The XDocument to read for comic metadata.</param>
/// <returns>The resulting book.</returns>
public static Book? ReadComicBookMetadata(XDocument xml)
{
var book = new Book();
var hasFoundMetadata = false;
// this value is only used internally since Jellyfin has no manga flag
var isManga = false;
hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Title", title => book.Name = title);
hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Manga", manga => isManga = manga.Equals("Yes", StringComparison.OrdinalIgnoreCase));
hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Series", series => book.SeriesName = series);
hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Number", issue => book.IndexNumber = issue);
hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Summary", summary => book.Overview = summary);
hasFoundMetadata |= ReadIntInto(xml, "ComicInfo/Year", year => book.ProductionYear = year);
hasFoundMetadata |= ReadThreePartDateInto(xml, "ComicInfo/Year", "ComicInfo/Month", "ComicInfo/Day", dateTime => book.PremiereDate = dateTime);
hasFoundMetadata |= ReadCommaSeparatedStringsInto(xml, "ComicInfo/Genre", genres => genres.ForEach(genre => book.AddGenre(genre)));
hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/Publisher", publisher => book.SetStudios([publisher]));
hasFoundMetadata |= ReadStringInto(xml, "ComicInfo/AlternateSeries", title =>
{
if (isManga)
{
// Software like ComicTagger (https://github.com/comictagger/comictagger) will use
// this field for the series name in the original language when tagging manga.
book.OriginalTitle = title;
}
else
{
// Some US comics can be part of cross-over story arcs. This field is then used to
// specify an alternate series.
}
});
return hasFoundMetadata ? book : null;
}
/// <summary>
/// Read people metadata.
/// </summary>
/// <param name="xml">The XDocument to read for people metadata.</param>
/// <param name="metadataResult">The metadata result to update.</param>
public static void ReadPeopleMetadata(XDocument xml, MetadataResult<Book> metadataResult)
{
ReadCommaSeparatedStringsInto(xml, "ComicInfo/Writer", authors =>
{
authors.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Author }));
});
ReadCommaSeparatedStringsInto(xml, "ComicInfo/Penciller", pencillers =>
{
pencillers.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Penciller }));
});
ReadCommaSeparatedStringsInto(xml, "ComicInfo/Inker", inkers =>
{
inkers.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Inker }));
});
ReadCommaSeparatedStringsInto(xml, "ComicInfo/Letterer", letterers =>
{
letterers.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Letterer }));
});
ReadCommaSeparatedStringsInto(xml, "ComicInfo/CoverArtist", artists =>
{
artists.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.CoverArtist }));
});
ReadCommaSeparatedStringsInto(xml, "ComicInfo/Colourist", colorists =>
{
colorists.ForEach(p => metadataResult.AddPerson(new PersonInfo { Name = p, Type = PersonKind.Colorist }));
});
}
/// <summary>
/// Read culture information.
/// </summary>
/// <param name="xml">the XDocument to read for metadata.</param>
/// <param name="xPath">The path to search.</param>
/// <param name="commitResult">The action to take after parsing all metadata.</param>
public static void ReadCultureInfoInto(XDocument xml, string xPath, Action<CultureInfo> commitResult)
{
string? culture = null;
if (!ReadStringInto(xml, xPath, value => culture = value))
{
return;
}
// culture cannot be null here as the method would have returned earlier
commitResult(new CultureInfo(culture!));
}
private static bool ReadStringInto(XDocument xml, string xPath, Action<string> commitResult)
{
var resultElement = xml.XPathSelectElement(xPath);
if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value))
{
commitResult(resultElement.Value);
return true;
}
return false;
}
private static bool ReadCommaSeparatedStringsInto(XDocument xml, string xPath, Action<IEnumerable<string>> commitResult)
{
var resultElement = xml.XPathSelectElement(xPath);
if (resultElement is null || string.IsNullOrWhiteSpace(resultElement.Value))
{
return false;
}
try
{
var splits = resultElement.Value.Split(",").Select(p => p.Trim()).ToArray();
if (splits.Length < 1)
{
return false;
}
commitResult(splits);
return true;
}
catch (ArgumentNullException)
{
return false;
}
}
private static bool ReadIntInto(XDocument xml, string xPath, Action<int> commitResult)
{
var resultElement = xml.XPathSelectElement(xPath);
if (resultElement is not null && !string.IsNullOrWhiteSpace(resultElement.Value))
{
return ParseInt(resultElement.Value, commitResult);
}
return false;
}
private static bool ReadThreePartDateInto(XDocument xml, string yearXPath, string monthXPath, string dayXPath, Action<DateTime> commitResult)
{
int year = 0;
int month = 0;
int day = 0;
var parsed = false;
parsed |= ReadIntInto(xml, yearXPath, num => year = num);
parsed |= ReadIntInto(xml, monthXPath, num => month = num);
parsed |= ReadIntInto(xml, dayXPath, num => day = num);
if (!parsed)
{
return false;
}
try
{
var dateTime = new DateTime(year, month, day, 0, 0, 0, DateTimeKind.Unspecified);
commitResult(dateTime);
return true;
}
catch (ArgumentOutOfRangeException)
{
return false;
}
}
private static bool ParseInt(string input, Action<int> commitResult)
{
if (int.TryParse(input, out var parsed))
{
commitResult(parsed);
return true;
}
return false;
}
}

View File

@@ -0,0 +1,99 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using System.Xml.Linq;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Books.ComicInfo;
/// <summary>
/// Handles metadata for comics which is saved as an XML document. This XML document is not part
/// of the comic itself but an external file.
/// </summary>
public class ExternalComicInfoProvider : IComicProvider
{
private readonly IFileSystem _fileSystem;
private readonly ILogger<ExternalComicInfoProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ExternalComicInfoProvider"/> class.
/// </summary>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{ExternalComicInfoProvider}"/> interface.</param>
public ExternalComicInfoProvider(IFileSystem fileSystem, ILogger<ExternalComicInfoProvider> logger)
{
_logger = logger;
_fileSystem = fileSystem;
}
/// <inheritdoc />
public async ValueTask<MetadataResult<Book>> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
{
var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false);
if (comicInfoXml is null)
{
_logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file.", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml);
if (book is null)
{
return new MetadataResult<Book> { HasMetadata = false };
}
var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
return metadataResult;
}
/// <inheritdoc />
public bool HasItemChanged(BaseItem item)
{
var file = GetXmlFilePath(item.Path);
return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
}
private async Task<XDocument?> LoadXml(ItemInfo info, CancellationToken cancellationToken)
{
var path = GetXmlFilePath(info.Path).FullName;
if (path is null)
{
return null;
}
try
{
using var reader = XmlReader.Create(path, new XmlReaderSettings { Async = true });
var comicInfoXml = XDocument.LoadAsync(reader, LoadOptions.None, cancellationToken);
return await comicInfoXml.ConfigureAwait(false);
}
catch (Exception e)
{
_logger.LogInformation(e, "Could not load external XML from {Path}. This could mean there is no separate ComicInfo metadata file for this comic or the metadata is bundled within the comic.", path);
return null;
}
}
private FileSystemMetadata GetXmlFilePath(string path)
{
var fileInfo = _fileSystem.GetFileSystemInfo(path);
var directoryInfo = fileInfo.IsDirectory ? fileInfo : _fileSystem.GetDirectoryInfo(Path.GetDirectoryName(path)!);
var file = _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, Path.GetFileNameWithoutExtension(path) + ".xml"));
return file.Exists ? file : _fileSystem.GetFileInfo(Path.Combine(directoryInfo.FullName, ComicInfoReader.ComicRackMetaFile));
}
}

View File

@@ -0,0 +1,120 @@
using System;
using System.IO.Compression;
using System.Threading;
using System.Threading.Tasks;
using System.Xml.Linq;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Books.ComicInfo;
/// <summary>
/// Handles metadata for comics which is saved as an XML document inside the comic itself.
/// </summary>
public class InternalComicInfoProvider : IComicProvider
{
private readonly IFileSystem _fileSystem;
private readonly ILogger<InternalComicInfoProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="InternalComicInfoProvider"/> class.
/// </summary>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{InternalComicInfoProvider}"/> interface.</param>
public InternalComicInfoProvider(IFileSystem fileSystem, ILogger<InternalComicInfoProvider> logger)
{
_logger = logger;
_fileSystem = fileSystem;
}
/// <inheritdoc />
public async ValueTask<MetadataResult<Book>> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
{
var comicInfoXml = await LoadXml(info, cancellationToken).ConfigureAwait(false);
if (comicInfoXml is null)
{
_logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
var book = ComicInfoReader.ReadComicBookMetadata(comicInfoXml);
if (book is null)
{
return new MetadataResult<Book> { HasMetadata = false };
}
var metadataResult = new MetadataResult<Book> { Item = book, HasMetadata = true };
ComicInfoReader.ReadPeopleMetadata(comicInfoXml, metadataResult);
ComicInfoReader.ReadCultureInfoInto(comicInfoXml, "ComicInfo/LanguageISO", cultureInfo => metadataResult.ResultLanguage = cultureInfo.ThreeLetterISOLanguageName);
return metadataResult;
}
/// <inheritdoc />
public bool HasItemChanged(BaseItem item)
{
var file = GetComicBookFile(item.Path);
if (file is null)
{
return false;
}
return file.Exists && _fileSystem.GetLastWriteTimeUtc(file) > item.DateLastSaved;
}
private async Task<XDocument?> LoadXml(ItemInfo info, CancellationToken cancellationToken)
{
var path = GetComicBookFile(info.Path)?.FullName;
if (path is null)
{
return null;
}
try
{
// open the comic archive and try to get the ComicInfo.xml entry
using var comicBookFile = await ZipFile.OpenReadAsync(path, cancellationToken).ConfigureAwait(false);
var container = comicBookFile.GetEntry(ComicInfoReader.ComicRackMetaFile);
if (container is null)
{
return null;
}
using var containerStream = await container.OpenAsync(cancellationToken).ConfigureAwait(false);
var comicInfoXml = XDocument.LoadAsync(containerStream, LoadOptions.None, cancellationToken);
return await comicInfoXml.ConfigureAwait(false);
}
catch (Exception e)
{
_logger.LogError(e, "could not load internal XML from {Path}", path);
return null;
}
}
private FileSystemMetadata? GetComicBookFile(string path)
{
var fileInfo = _fileSystem.GetFileSystemInfo(path);
if (fileInfo.IsDirectory)
{
return null;
}
// only parse files that are known to have internal metadata
if (!string.Equals(fileInfo.Extension, ".cbz", StringComparison.OrdinalIgnoreCase))
{
return null;
}
return fileInfo;
}
}

View File

@@ -0,0 +1,59 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
namespace MediaBrowser.Providers.Books;
/// <summary>
/// Comic provider.
/// </summary>
public class ComicProvider : ILocalMetadataProvider<Book>, IHasItemChangeMonitor
{
private readonly IEnumerable<IComicProvider> _comicProviders;
/// <summary>
/// Initializes a new instance of the <see cref="ComicProvider"/> class.
/// </summary>
/// <param name="comicProviders">The list of comic providers.</param>
public ComicProvider(IEnumerable<IComicProvider> comicProviders)
{
_comicProviders = comicProviders;
}
/// <inheritdoc />
public string Name => "Comic Provider";
/// <inheritdoc />
public async Task<MetadataResult<Book>> GetMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken)
{
foreach (IComicProvider comicProvider in _comicProviders)
{
var metadata = await comicProvider.ReadMetadata(info, directoryService, cancellationToken).ConfigureAwait(false);
if (metadata.HasMetadata)
{
return metadata;
}
}
return new MetadataResult<Book> { HasMetadata = false };
}
/// <inheritdoc />
public bool HasChanged(BaseItem item, IDirectoryService directoryService)
{
foreach (IComicProvider iComicFileProvider in _comicProviders)
{
var fileChanged = iComicFileProvider.HasItemChanged(item);
if (fileChanged)
{
return fileChanged;
}
}
return false;
}
}

View File

@@ -0,0 +1,23 @@
using MediaBrowser.Controller;
using MediaBrowser.Controller.Plugins;
using MediaBrowser.Providers.Books.ComicBookInfo;
using MediaBrowser.Providers.Books.ComicInfo;
using Microsoft.Extensions.DependencyInjection;
namespace MediaBrowser.Providers.Books;
/// <inheritdoc />
public class ComicServiceRegistrator : IPluginServiceRegistrator
{
/// <inheritdoc />
public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost)
{
// register the generic local metadata provider for comic files
serviceCollection.AddSingleton<ComicProvider>();
// register the actual implementations of the local metadata provider for comic files
serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>();
serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>();
serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>();
}
}

View File

@@ -0,0 +1,28 @@
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Providers;
namespace MediaBrowser.Providers.Books;
/// <summary>
/// Comic provider interface.
/// </summary>
public interface IComicProvider
{
/// <summary>
/// Read the item metadata.
/// </summary>
/// <param name="info">The item information.</param>
/// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The metadata result.</returns>
ValueTask<MetadataResult<Book>> ReadMetadata(ItemInfo info, IDirectoryService directoryService, CancellationToken cancellationToken);
/// <summary>
/// Determine whether the item has changed.
/// </summary>
/// <param name="item">The item.</param>
/// <returns>Item change status.</returns>
bool HasItemChanged(BaseItem item);
}

View File

@@ -22,6 +22,7 @@
<PackageReference Include="Microsoft.Extensions.Http" /> <PackageReference Include="Microsoft.Extensions.Http" />
<PackageReference Include="Newtonsoft.Json" /> <PackageReference Include="Newtonsoft.Json" />
<PackageReference Include="PlaylistsNET" /> <PackageReference Include="PlaylistsNET" />
<PackageReference Include="SharpCompress" />
<PackageReference Include="z440.atl.core" /> <PackageReference Include="z440.atl.core" />
<PackageReference Include="TMDbLib" /> <PackageReference Include="TMDbLib" />
</ItemGroup> </ItemGroup>

View File

@@ -102,7 +102,8 @@ namespace MediaBrowser.Providers.MediaInfo
DtoOptions = new DtoOptions(true), DtoOptions = new DtoOptions(true),
SourceTypes = new[] { SourceType.Library }, SourceTypes = new[] { SourceType.Library },
Parent = library, Parent = library,
Recursive = true Recursive = true,
IncludeOwnedItems = true
}; };
if (skipIfAudioTrackMatches) if (skipIfAudioTrackMatches)

View File

@@ -95,7 +95,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
var posters = movie.Images.Posters; var posters = movie.Images.Posters;
var backdrops = movie.Images.Backdrops; var backdrops = movie.Images.Backdrops;
var logos = movie.Images.Logos; var logos = movie.Images.Logos;
var remoteImages = new List<RemoteImageInfo>(posters?.Count ?? 0 + backdrops?.Count ?? 0 + logos?.Count ?? 0); var remoteImages = new List<RemoteImageInfo>((posters?.Count ?? 0) + (backdrops?.Count ?? 0) + (logos?.Count ?? 0));
if (posters is not null) if (posters is not null)
{ {

View File

@@ -210,16 +210,19 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
return true; return true;
} }
// Not yet processed // Episode has been processed and linked to a season, only needs a virtual season
if (episode.SeasonId.IsEmpty()) // if it isn't already linked to a known physical season by ID or path
if (!episode.SeasonId.IsEmpty())
{ {
return false; return !physicalSeasonIds.Contains(episode.SeasonId)
&& !physicalSeasonPaths.Contains(System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty);
} }
// Episode has been processed, only needs a virtual season if it isn't // Episode not yet linked, check if it's in a physical season folder
// already linked to a known physical season by ID or path // If yes then skip it, processing not finished
return !physicalSeasonIds.Contains(episode.SeasonId) // If no then include it, needs Season Unknown
&& !physicalSeasonPaths.Contains(System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty); var episodeDirectory = System.IO.Path.GetDirectoryName(episode.Path) ?? string.Empty;
return !physicalSeasonPaths.Contains(episodeDirectory);
} }
/// <summary> /// <summary>

View File

@@ -111,6 +111,92 @@ public static class JellyfinQueryHelperExtensions
&& val.map.ItemId == item.Id) == EF.Constant(!invert); && val.map.ItemId == item.Id) == EF.Constant(!invert);
} }
/// <summary>
/// Filters items that match any of the specified (provider name, value) pairs.
/// </summary>
/// <param name="baseQuery">The source query.</param>
/// <param name="providerIds">Dictionary mapping provider names to arrays of values to match.</param>
/// <returns>A filtered query.</returns>
public static IQueryable<BaseItemEntity> WhereHasAnyProviderIds(
this IQueryable<BaseItemEntity> baseQuery,
IReadOnlyDictionary<string, string[]> providerIds)
{
var providerKeys = providerIds
.SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}"))
.ToList();
if (providerKeys.Count == 0)
{
return baseQuery;
}
return baseQuery.Where(e => e.Provider!.Any(p => providerKeys.Contains(p.ProviderId + ":" + p.ProviderValue)));
}
/// <summary>
/// Filters items that have any of the specified providers. Empty/null values match any value for that provider.
/// </summary>
/// <param name="baseQuery">The source query.</param>
/// <param name="providerIds">Dictionary mapping provider names to optional values.</param>
/// <returns>A filtered query.</returns>
public static IQueryable<BaseItemEntity> WhereHasAnyProviderId(
this IQueryable<BaseItemEntity> baseQuery,
IReadOnlyDictionary<string, string> providerIds)
{
var existenceOnly = providerIds
.Where(e => string.IsNullOrEmpty(e.Value))
.Select(e => e.Key)
.ToList();
var specificValues = providerIds
.Where(e => !string.IsNullOrEmpty(e.Value))
.Select(e => $"{e.Key}:{e.Value}")
.ToList();
if (existenceOnly.Count == 0 && specificValues.Count == 0)
{
return baseQuery;
}
if (existenceOnly.Count == 0)
{
return baseQuery.Where(e => e.Provider!.Any(p =>
specificValues.Contains(p.ProviderId + ":" + p.ProviderValue)));
}
if (specificValues.Count == 0)
{
return baseQuery.Where(e => e.Provider!.Any(p => existenceOnly.Contains(p.ProviderId)));
}
// Single EXISTS over Provider with both predicates OR'd, instead of two separate subqueries.
return baseQuery.Where(e => e.Provider!.Any(p =>
existenceOnly.Contains(p.ProviderId) ||
specificValues.Contains(p.ProviderId + ":" + p.ProviderValue)));
}
/// <summary>
/// Excludes items that match any of the specified (provider name, value) pairs.
/// </summary>
/// <param name="baseQuery">The source query.</param>
/// <param name="providerIds">Dictionary mapping provider names to values to exclude.</param>
/// <returns>A filtered query.</returns>
public static IQueryable<BaseItemEntity> WhereExcludeProviderIds(
this IQueryable<BaseItemEntity> baseQuery,
IReadOnlyDictionary<string, string> providerIds)
{
var excludeKeys = providerIds
.Select(e => $"{e.Key}:{e.Value}")
.ToList();
if (excludeKeys.Count == 0)
{
return baseQuery;
}
return baseQuery.Where(e => e.Provider!.All(p => !excludeKeys.Contains(p.ProviderId + ":" + p.ProviderValue)));
}
/// <summary> /// <summary>
/// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query. /// Builds an optimised query expression checking one property against a list of values while maintaining an optimal query.
/// </summary> /// </summary>
@@ -138,9 +224,10 @@ public static class JellyfinQueryHelperExtensions
var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key)); var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key));
if (oneOf.Count < 4) // arbitrary value choosen. // Threshold picked from microbenchmarks on SQLite: inline IN(const,...) beats a
// parameterized array lookup by ~5-10% up to ~32 elements.
if (oneOf.Count <= 32)
{ {
// if we have 3 or fewer values to check against its faster to do a IN(const,const,const) lookup
return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter); return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter);
} }

View File

@@ -448,14 +448,19 @@ public class GuideManager : IGuideManager
item.Name = channelInfo.Name; item.Name = channelInfo.Name;
if (!item.HasImage(ImageType.Primary)) var currentPrimary = item.GetImageInfo(ImageType.Primary, 0);
var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl);
// Update channel image if image URL has changed
if (currentPrimary is null
|| (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal)))
{ {
if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath)) if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
{ {
item.SetImagePath(ImageType.Primary, channelInfo.ImagePath); item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
forceUpdate = true; forceUpdate = true;
} }
else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl)) else if (!imageUrlIsNull)
{ {
item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl); item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
forceUpdate = true; forceUpdate = true;

View File

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

View File

@@ -0,0 +1,99 @@
using System;
using System.Globalization;
using Jellyfin.Api.Helpers;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Jellyfin.Api.Tests.Helpers
{
public class MediaInfoHelperTests
{
private static MediaInfoHelper CreateHelper()
{
return new MediaInfoHelper(
Mock.Of<IUserManager>(),
Mock.Of<ILibraryManager>(),
Mock.Of<IMediaSourceManager>(),
Mock.Of<IMediaEncoder>(),
Mock.Of<IServerConfigurationManager>(),
Mock.Of<ILogger<MediaInfoHelper>>(),
Mock.Of<INetworkManager>(),
Mock.Of<IDeviceManager>());
}
private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true)
{
return new MediaSourceInfo
{
Id = itemId.ToString("N", CultureInfo.InvariantCulture),
Protocol = MediaProtocol.File,
Bitrate = bitrate,
SupportsDirectPlay = supportsDirectPlay,
SupportsDirectStream = true,
SupportsTranscoding = true
};
}
[Fact]
public void SortMediaSources_PreferredItemExceedsBitrate_StaysDefault()
{
// The version the user was watching (the queried item) must stay the default
// even when a sibling version fits the bitrate limit better, since the resume
// position belongs to that exact version.
var preferredItemId = Guid.NewGuid();
var preferredSource = CreateSource(preferredItemId, bitrate: 80_000_000, supportsDirectPlay: false);
var siblingSource = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
var result = new PlaybackInfoResponse
{
MediaSources = [siblingSource, preferredSource]
};
CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, preferredItemId);
Assert.Equal(preferredSource.Id, result.MediaSources[0].Id);
}
[Fact]
public void SortMediaSources_NoPreferredItem_OrdersByPlayability()
{
var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false);
transcodeOnly.SupportsDirectStream = false;
var result = new PlaybackInfoResponse
{
MediaSources = [transcodeOnly, directPlay]
};
CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000);
Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
}
[Fact]
public void SortMediaSources_PreferredIdNotInSources_KeepsPlayabilityOrder()
{
var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false);
transcodeOnly.SupportsDirectStream = false;
var result = new PlaybackInfoResponse
{
MediaSources = [transcodeOnly, directPlay]
};
CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, Guid.NewGuid());
Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
}
}
}

View File

@@ -0,0 +1,137 @@
using System;
using Emby.Server.Implementations.Dto;
using Emby.Server.Implementations.Playlists;
using Jellyfin.Data.Enums;
using MediaBrowser.Common;
using MediaBrowser.Controller.Chapters;
using MediaBrowser.Controller.Drawing;
using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Trickplay;
using MediaBrowser.Model.Entities;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Dto;
public class DtoServiceImageInheritanceTests
{
[Fact]
public void GetBaseItemDto_PlaylistsUserViewWithDisplayParentPrimary_UsesDisplayParentPrimaryImage()
{
var displayParent = new PlaylistsFolder
{
Id = Guid.NewGuid(),
ImageInfos =
[
new ItemImageInfo
{
Type = ImageType.Primary,
Path = "/images/playlists-custom.jpg",
DateModified = new DateTime(2026, 1, 15, 12, 0, 0, DateTimeKind.Utc)
}
]
};
var userView = new UserView
{
Id = Guid.NewGuid(),
ViewType = CollectionType.playlists,
DisplayParentId = displayParent.Id,
ImageInfos =
[
new ItemImageInfo
{
Type = ImageType.Primary,
Path = "/images/generated.png",
DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
}
]
};
var dtoService = BuildDtoService(displayParent);
var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
Assert.NotNull(dto.ParentPrimaryImageItemId);
Assert.Equal(displayParent.Id, dto.ParentPrimaryImageItemId);
Assert.Equal("/images/playlists-custom.jpg", dto.ParentPrimaryImageTag);
Assert.False(dto.ImageTags?.ContainsKey(ImageType.Primary));
}
[Fact]
public void GetBaseItemDto_PlaylistsUserViewWithoutDisplayParentPrimary_KeepsOwnPrimaryImage()
{
var displayParent = new PlaylistsFolder
{
Id = Guid.NewGuid(),
ImageInfos = []
};
var userView = new UserView
{
Id = Guid.NewGuid(),
ViewType = CollectionType.playlists,
DisplayParentId = displayParent.Id,
ImageInfos =
[
new ItemImageInfo
{
Type = ImageType.Primary,
Path = "/images/generated.png",
DateModified = new DateTime(2026, 1, 10, 12, 0, 0, DateTimeKind.Utc)
}
]
};
var dtoService = BuildDtoService(displayParent);
var dto = dtoService.GetBaseItemDto(userView, new DtoOptions(false));
Assert.Null(dto.ParentPrimaryImageItemId);
Assert.Null(dto.ParentPrimaryImageTag);
Assert.NotNull(dto.ImageTags);
Assert.True(dto.ImageTags.ContainsKey(ImageType.Primary));
Assert.Equal("/images/generated.png", dto.ImageTags[ImageType.Primary]);
}
private static DtoService BuildDtoService(BaseItem displayParent)
{
var libraryManager = new Mock<ILibraryManager>();
var userDataManager = new Mock<IUserDataManager>();
var imageProcessor = new Mock<IImageProcessor>();
var providerManager = new Mock<IProviderManager>();
var recordingsManager = new Mock<IRecordingsManager>();
var appHost = new Mock<IApplicationHost>();
var mediaSourceManager = new Mock<IMediaSourceManager>();
var liveTvManager = new Mock<ILiveTvManager>();
var trickplayManager = new Mock<ITrickplayManager>();
var chapterManager = new Mock<IChapterManager>();
var logger = new Mock<Microsoft.Extensions.Logging.ILogger<DtoService>>();
libraryManager
.Setup(x => x.GetItemById(displayParent.Id))
.Returns(displayParent);
imageProcessor
.Setup(x => x.GetImageCacheTag(It.IsAny<BaseItem>(), It.IsAny<ItemImageInfo>()))
.Returns<BaseItem, ItemImageInfo>((_, image) => image.Path);
return new DtoService(
logger.Object,
libraryManager.Object,
userDataManager.Object,
imageProcessor.Object,
providerManager.Object,
recordingsManager.Object,
appHost.Object,
mediaSourceManager.Object,
new Lazy<ILiveTvManager>(() => liveTvManager.Object),
trickplayManager.Object,
chapterManager.Object);
}
}

View File

@@ -0,0 +1,93 @@
using System;
using System.Threading.Tasks;
using Jellyfin.Server.Implementations.Users;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Users
{
public class UserManagerLockHelperTests
{
[Fact]
public async Task LockAsync_WhenNested_DoesNotAcquireSecondLockAndRestoresStateOnDispose()
{
UserManager.LockHelper.IsNestedLock.Value = 0;
using var helper = new UserManager.LockHelper();
var key = Guid.NewGuid();
Assert.True(helper.ShouldLock());
var outerHandle = await helper.LockAsync(key);
Assert.False(helper.ShouldLock());
var innerHandle = await helper.LockAsync(key);
Assert.False(helper.ShouldLock());
innerHandle.Dispose();
Assert.False(helper.ShouldLock());
outerHandle.Dispose();
Assert.True(helper.ShouldLock());
}
[Fact]
public async Task LockAsync_WithSameKey_BlocksSecondLockUntilFirstIsReleased()
{
UserManager.LockHelper.IsNestedLock.Value = 0;
using var helper = new UserManager.LockHelper();
var key = Guid.NewGuid();
var firstAcquired = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var releaseFirst = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
var secondEntered = false;
var firstTask = Task.Run(
async () =>
{
using var firstHandle = await helper.LockAsync(key);
firstAcquired.SetResult(true);
await releaseFirst.Task;
},
TestContext.Current.CancellationToken);
await firstAcquired.Task;
var secondTask = Task.Run(
async () =>
{
using var secondHandle = await helper.LockAsync(key);
secondEntered = true;
},
TestContext.Current.CancellationToken);
await Task.Delay(100, TestContext.Current.CancellationToken);
Assert.False(secondEntered);
releaseFirst.SetResult(true);
await Task.WhenAll(firstTask, secondTask);
Assert.True(secondEntered);
}
[Fact]
public async Task LockAsync_WhenDisposed_ThrowsObjectDisposedException()
{
UserManager.LockHelper.IsNestedLock.Value = 0;
using var helper = new UserManager.LockHelper();
helper.Dispose();
await Assert.ThrowsAsync<ObjectDisposedException>(async () => await helper.LockAsync(Guid.NewGuid()));
}
[Fact]
public void Dispose_WhenCalledMultipleTimes_DoesNotThrow()
{
UserManager.LockHelper.IsNestedLock.Value = 0;
using var helper = new UserManager.LockHelper();
helper.Dispose();
var ex = Record.Exception(() => helper.Dispose());
Assert.Null(ex);
}
}
}