Compare commits

...

112 Commits

Author SHA1 Message Date
Jellyfin Release Bot 6c073e19dd Bump version to 12.0 2026-09-07 21:05:07 -04:00
Cody Robibero 71787c46d1 Merge pull request #17826 from joshuaboniface/fix/bump_version
Fix bump_version by removing Jellyfin.MediaEncoding.Keyframes
2026-09-07 21:03:02 -04:00
Joshua M. Boniface 35a391e99d Fix bump_version by removing Jellyfin.MediaEncoding.Keyframes
This file previously had the version in it, but it was removed in #17582
which caused this to fail. Remove it here.
2026-09-07 20:58:17 -04:00
Cody Robibero 85ebd83bdd Merge pull request #17821 from Shadowghost/more-counts
Expose optimized ItemCounts for byName items
2026-09-07 17:53:31 -04:00
Cody Robibero 882faf2b2d Merge pull request #17723 from jellyfin/renovate/bitfaster.caching-2.x
Update dependency BitFaster.Caching to 2.6.1
2026-09-07 17:44:24 -04:00
Cody Robibero d7d6cc44de Merge pull request #17824 from Raspberry-Monster/master
Fix Blu-ray multi-angle concat generation
2026-09-07 17:44:06 -04:00
Cody Robibero 42f7a6e720 Merge pull request #17820 from Shadowghost/only-download-missing-plugin-images
Only download missing plugin images
2026-09-07 17:43:57 -04:00
Cody Robibero 96f81b0c1b Merge pull request #17775 from slevin-7/fix-skia-sharpen-perf
Apply the resize sharpening kernel directly instead of via SKImageFilter
2026-09-07 17:43:48 -04:00
Cody Robibero 7d9bd1d3e4 Merge pull request #17816 from Shadowghost/version-aware-played-state
Share played state across alternate versions
2026-09-07 17:43:10 -04:00
Cody Robibero 2ffa74e7ea Merge pull request #17819 from Shadowghost/fix-tmdb-search
Fix TMDb search result ranking
2026-09-07 17:42:38 -04:00
krvi 837c34aee2 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-09-07 20:23:12 +00:00
renovate[bot] 0fc380b4a9 Update dependency BitFaster.Caching to 2.6.1 2026-09-07 19:46:42 +00:00
Shadowghost 2644163f57 Count distinct items for byName ItemCounts and batch every kind 2026-09-07 18:39:59 +02:00
krvi dfe57e484f Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-09-07 15:15:24 +00:00
Raspberry Kan 647c4ad090 Fix Blu-ray multi-angle concat generation
Filter Blu-ray playlist clips to AngleIndex == 0 when generating concat files to prevent clips from multiple angles from being played sequentially.
2026-09-07 22:53:22 +08:00
nextlooper42 6de9d1508f Translated using Weblate (Slovak)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/sk/
2026-09-07 12:22:59 +00:00
Anastasis Marinos b2eb088e0f Translated using Weblate (Greek)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/el/
2026-09-07 12:22:59 +00:00
Shadowghost e1e319cb5c Only download missing plugin images 2026-09-07 10:43:36 +02:00
Shadowghost 59cd5f983b Expose optimized ItemCounts for byName items 2026-09-07 10:30:01 +02:00
Shadowghost 04a7568902 Fix TMDb search result ranking 2026-09-07 08:23:31 +02:00
Cody Robibero 3e7d1158da Merge pull request #17794 from Shadowghost/plugin-install-fixes
Better handle timeouts on plugin operations
2026-09-06 12:47:45 -04:00
Shadowghost fd016cfb13 Share played state across alternate versions 2026-09-06 17:20:18 +02:00
Cody Robibero f898c35b96 Merge pull request #17791 from Shadowghost/fix-code-migration
Don't dispose application singletons after running a code migration
2026-09-06 07:45:43 -04:00
Cody Robibero bbb3a48963 Merge pull request #17810 from Shadowghost/fix-tmdb-recommendations
Fix TMDb recommendations
2026-09-06 07:37:18 -04:00
Cody Robibero 34feb3c04a Merge pull request #17763 from Shadowghost/fix-refresh-queue-and-directory-cache
Fix items being lost from the refresh queue and bound the directory service caches
2026-09-06 07:36:13 -04:00
Cody Robibero 8d108d2870 Merge pull request #17806 from jellyfin/fix/dovi-color-validation
Enforce dolby vision transfer check
2026-09-06 07:35:32 -04:00
Cody Robibero 2555ab7539 Merge pull request #17805 from Shadowghost/fix-unordered-multiepisode-nfo
Fix handling of unordered multi-episode NFOs
2026-09-06 07:34:45 -04:00
Cody Robibero edb6cd11da Merge branch 'master' into fix-code-migration 2026-09-06 07:34:30 -04:00
Cody Robibero a0a42c630e Merge pull request #17786 from Shadowghost/optimize-db
Optimize database after migrations
2026-09-06 07:33:07 -04:00
Shadowghost 63553803b1 Remove the unreachable null service provider path from code migrations 2026-09-06 09:49:39 +02:00
Shadowghost 7b638a22bb Add test 2026-09-06 09:07:22 +02:00
gnattu 011eed7622 fix dovi invalid doc 2026-09-06 14:50:30 +08:00
Shadowghost 344a6dcd2c Apply review suggestions 2026-09-06 08:38:43 +02:00
Shadowghost 79a6ac6d82 Don't read episode markers in episode titles as a multi-episode range 2026-09-06 08:04:34 +02:00
Shadowghost ca90347dc2 Resolve migration routine loggers from the application container 2026-09-06 07:54:11 +02:00
Cody Robibero 66d038c403 Merge pull request #17762 from Shadowghost/fix-scan-memory-leak
Bound change batches during a scan; keep ffprobe and image saves from failing
2026-09-06 01:28:02 -04:00
Shadowghost 2ac4dd9980 Fix TMDb recommendations 2026-09-06 06:42:53 +02:00
gnattu a1910a7b2e Enforce dolby vision transfer check
Dolby vision files having unexpected transfers now also marked as invalid, and it will now get its base video range from its base layer color transfer, as not all invalid dolby vision files are HDR now.
2026-09-06 02:53:52 +08:00
Shadowghost 9221e22498 Do not resolve bundled plugins via repos 2026-09-05 20:01:09 +02:00
Shadowghost 006809f02a Fix handling of unordered multi-episode NFOs 2026-09-05 19:32:56 +02:00
Shadowghost 93345f812e FIx naming 2026-09-05 18:49:27 +02:00
Cody Robibero 7c463f5fba Merge pull request #17674 from Oggeb1/BDMV-pgs
Fix PGS subtitles for BDMV with TrueHD
2026-09-05 12:16:25 -04:00
Shadowghost 74ce774eff Revert to doing after migration and on shutdown 2026-09-05 17:33:47 +02:00
Oggeb1 9528d0e601 Fix BDMV subtitles with external audio
Co-authored-by: gnattu <gnattu@users.noreply.github.com>
2026-09-05 17:15:00 +02:00
Cody Robibero 84589380e5 Merge pull request #17746 from kfarnung/fix/vobsub-idx-subtitle-language
Probe .idx instead of .sub for external VobSub subtitle language detection
2026-09-05 11:09:46 -04:00
Cody Robibero ffd61de4f3 Merge pull request #17799 from fmarcac/fix/syncplay-stale-session-requests
Drop SyncPlay requests from sessions that left the group
2026-09-05 11:07:56 -04:00
Cody Robibero 441a05ac88 Merge pull request #17768 from Shadowghost/fix-livetv-hls-audio-codec-and-manifest-direct-play
Fix Live TV HLS playback: bogus audio encoder and unplayable direct played manifests
2026-09-05 11:00:31 -04:00
Cody Robibero 44ecc909ff Merge pull request #17796 from fmarcac/fix/syncplay-shuffle-mode-crash
Fix crash when SyncPlay shuffle mode is set to sorted twice
2026-09-05 10:58:31 -04:00
Cody Robibero 2b0ccf2a30 Merge pull request #17800 from fmarcac/fix/syncplay-active-session-counter
Fix SyncPlay active session counter leaking on rejoin
2026-09-05 10:57:55 -04:00
Cody Robibero d4a23cd438 Merge pull request #17798 from fmarcac/fix/syncplay-ping-delay-units
Fix unit mismatch in the SyncPlay resume delay floor
2026-09-05 10:55:52 -04:00
fmarcac 7ce911a401 Clamp client reported ping in SyncPlay groups 2026-09-05 15:06:37 +02:00
fmarcac e356fe9146 Return the default ping for an empty SyncPlay group 2026-09-05 14:24:49 +02:00
fmarcac 0c05d9d1a9 Apply the SyncPlay resume delay floor in the correct unit 2026-09-05 14:24:49 +02:00
fmarcac e5bfe562bc Fix crash when SyncPlay shuffle mode is set to sorted twice 2026-09-05 14:24:48 +02:00
fmarcac 51a7d5d08a Fix SyncPlay active session counter leaking on rejoin 2026-09-05 14:24:48 +02:00
fmarcac 5acb200c02 Drop SyncPlay requests from sessions that left the group 2026-09-05 14:23:37 +02:00
Shadowghost f8470630be Retire runners that are cancelled before they start 2026-09-05 09:59:12 +02:00
Shadowghost bf5fb593e4 Do not treat timeouts as cancellations 2026-09-05 09:41:07 +02:00
Shadowghost 9c259027df Cleanup 2026-09-05 08:55:12 +02:00
Shadowghost c622a16261 Don't dispose application singletons after running a code migration 2026-09-05 07:37:09 +02:00
Shadowghost 5465e0c694 Optimize the database on startup instead of before shutdown 2026-09-05 07:36:17 +02:00
Shadowghost e5cd3381ac Limit cache size again
Co-Authored-By: Cody Robibero <cody@robibe.ro>
2026-09-05 07:09:29 +02:00
Cody Robibero c80f05fad1 Merge pull request #17767 from Shadowghost/post-scna-task-logging
Add comprehensive logging for post scan tasks
2026-09-04 21:13:54 -04:00
Shadowghost 46dd7d8e99 Invalidate the singleton DirectoryService cache on filesystem changes 2026-09-04 19:28:59 +02:00
Shadowghost 0d9c9c9ecc Fix the library scheduler never retiring its runners 2026-09-04 19:23:29 +02:00
Shadowghost 0b5bbb528a Optimize database after running migrations 2026-09-04 18:57:54 +02:00
krvi f10c84465a Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-09-04 16:54:10 +00:00
AlphaBeta 610887dc2c Translated using Weblate (Thai)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/th/
2026-09-04 15:42:44 +00:00
Shadowghost 5edb369e3b Apply review suggestion 2026-09-04 07:02:08 +02:00
Shadowghost fc37151fc4 Use platform separators in the DirectoryService path tests 2026-09-04 06:57:18 +02:00
Cody Robibero c96ff18e5b Merge pull request #17757 from felixfoertsch/fix/legacy-filter-tag-query
Fix UI stalls caused by large tag and genre sets
2026-09-03 18:36:37 -04:00
krvi 3f8315df4e Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-09-03 19:05:36 +00:00
Riccardo 1ccec11b91 Translated using Weblate (Bosnian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/bs/
2026-09-03 13:37:37 +00:00
Riccardo d18d4f9cd2 Translated using Weblate (Luxembourgish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lb/
2026-09-03 13:37:36 +00:00
Riccardo 978552b1c5 Translated using Weblate (Irish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ga/
2026-09-03 13:37:35 +00:00
Riccardo f53bb0aae9 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-09-03 13:37:35 +00:00
Riccardo 24d8290462 Translated using Weblate (Belarusian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/be/
2026-09-03 13:37:34 +00:00
Riccardo 849437991a Translated using Weblate (Latvian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lv/
2026-09-03 13:37:34 +00:00
Riccardo 1b184b2b35 Translated using Weblate (Romanian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ro/
2026-09-03 13:37:33 +00:00
Riccardo fb6e075e40 Translated using Weblate (Finnish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fi/
2026-09-03 13:37:33 +00:00
Riccardo 3682840d54 Translated using Weblate (Slovenian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/sl/
2026-09-03 13:37:32 +00:00
Riccardo a6c0fe57c5 Translated using Weblate (Norwegian Bokmål)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/nb_NO/
2026-09-03 13:37:31 +00:00
Riccardo f4d2da180d Translated using Weblate (Croatian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/hr/
2026-09-03 13:37:30 +00:00
Riccardo b561d6aa59 Translated using Weblate (Greek)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/el/
2026-09-03 13:37:29 +00:00
Shadowghost b724e57458 Reduce comments 2026-09-03 11:51:02 +02:00
Cody Robibero 6e4d98e6db Merge pull request #17764 from Shadowghost/fix-tmdb-cache-retention
Bound the TMDb response cache so a library scan cannot fill it without limit
2026-09-02 18:00:32 -04:00
Cody Robibero 4f2f781e68 Merge pull request #17756 from Shadowghost/fix-people-creation
Fix people and artist validator creation and deletion handling
2026-09-02 18:00:22 -04:00
Albert 064de2172e Apply the resize sharpening kernel directly instead of via SKImageFilter
Since the SkiaSharp 3 update the MatrixConvolution image filter used in
SkiaEncoder.ResizeImage no longer has a fast CPU path: on the software
rasterizer it takes about 4.5 seconds per megapixel-sized image, which
turns every cold image request into a multi-second operation and makes
first-time loads of a library view take minutes.

Draw the resize without the paint filter and apply the identical 3x3
kernel (same weights, clamped edges, alpha included) directly on the
resized pixels instead. This drops a cold 1000x1500 -> 663x995 poster
render from ~4.6s to well under a second; the convolution pass itself
takes ~86ms. Output is visually unchanged.
2026-09-02 21:41:36 +02:00
Felix Förtsch c3a7de54f3 avoid correlated item value name queries 2026-09-02 11:33:54 +02:00
Felix Förtsch 5adafb446f test legacy item value filter semantics 2026-09-02 11:29:24 +02:00
Felix Förtsch 3df58ab775 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-09-02 11:05:36 +02:00
Shadowghost 8eb4964599 Never treat a manifest container as an audio codec or a direct play target 2026-09-02 09:29:48 +02:00
Shadowghost f0297ee9ce Add comprehensive logging for post scan tasks 2026-09-02 09:26:41 +02:00
Shadowghost d73e3d964e Optimize Caches
Co-Authored-By: Cody Robibero <cody@robibe.ro>
2026-09-02 07:06:08 +02:00
Cody Robibero b3766b00d4 Merge pull request #17761 from zerafachris/fix/fallback-font-404-missing
fix: return fallback gracefully when requested fallback font is missing
2026-09-01 20:45:05 -04:00
Shadowghost 3bb8611995 Bound the TMDb response cache so a library scan cannot fill it without limit 2026-09-01 23:04:33 +02:00
Shadowghost e5dc3b8a54 Bound the directory caches a singleton would otherwise hold for the process lifetime 2026-09-01 22:04:06 +02:00
Shadowghost 0e6c52f431 Guard the refresh queue so concurrent callers cannot lose the items they queue 2026-09-01 22:03:59 +02:00
Shadowghost ccdc69e3b0 Treat an item deleted mid-save as a no-op when saving its images 2026-09-01 21:42:07 +02:00
Shadowghost c56e14d8fb Keep a media process and its exit state usable by the caller that started it 2026-09-01 21:42:06 +02:00
Shadowghost c5f8a93513 Close a change batch on its own window so a library scan cannot grow it without bound 2026-09-01 21:42:06 +02:00
Vitalijus ea89b6ebc5 Translated using Weblate (Lithuanian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lt/
2026-09-01 19:28:24 +00:00
Vitalijus 76418ec530 Translated using Weblate (Lithuanian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lt/
2026-09-01 18:33:23 +00:00
Shadowghost 50f08d41a4 Find dead people and artists by id, not by name 2026-09-01 19:49:25 +02:00
zerafachris 20678c9a8c fix: return fallback gracefully when requested fallback font is missing
GetFallbackFont() called .First() on the font file sequence, which throws
System.InvalidOperationException when no file matched the requested name,
causing HTTP 500. Change to .FirstOrDefault() so a missing font falls
through to the existing null guard and returns HTTP 200 OK (the empty
response is intentional to avoid breaking SubtitlesOctopus).

Fixes #17683.

Prepared with AI assistance (Claude Code, Anthropic), reviewed for correctness before submission.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-09-01 16:25:59 +02:00
Felix Förtsch 145258865a avoid correlated legacy item value filter queries
The legacy filter queries projected ItemValue before grouping tags and genres. EF Core translated that shape into duplicated correlated aggregates, causing multi-minute requests for libraries with many distinct values and blocking unrelated SQLite-backed API calls.

Join ItemValuesMap directly to ItemValues before grouping. This preserves type and item filtering, clean-value grouping, minimum-value selection and ordering while producing one aggregate query. Add an in-memory SQLite regression test for result semantics and both SQL shapes.
2026-09-01 14:37:44 +02:00
Shadowghost 792ce4a391 Fix people validator not creating missing people 2026-09-01 07:36:53 +02:00
Kyle Farnung 39c2885fd6 Probe .idx instead of .sub for external VobSub subtitle language detection
External VobSub subtitle pairs (.idx and .sub) were only probed via the
bare .sub file. In cases where multiple languages are present, this
results in missing language metadata.

Fix by detecting the matching .idx file during media info resolution
to run ffprobe on that file and skip processing the .sub entirely.
ffprobe will automatically find the matching (same directory,
case-sensitive base) .sub file and process both.

Added regression tests covering idx/sub pairing, unpaired files,
cross-directory pairs, and language-flagged filenames.

Fixes #17745
2026-08-30 17:29:49 -07:00
Oggeb1 4207e89a7d Merge branch 'jellyfin:master' into BDMV-pgs 2026-08-29 14:02:22 +02:00
Oskar Bali 163895b99f Fix BDMV with external subtitle
GetSubtitleStreamIndexForFfmpeg treated
external and internal subtitles the same. This
made subtitles out of sync with the video.
2026-08-29 13:52:20 +02:00
Oggeb1 c15656280e Merge branch 'jellyfin:master' into BDMV-pgs 2026-08-20 10:56:35 +02:00
Oskar Bali 0b20d7a05b Fix BDMV PGS subtitles with TrueHD
Add myself CONTRIBUTORS.md
2026-08-19 12:19:32 +02:00
117 changed files with 6269 additions and 759 deletions
+1
View File
@@ -240,6 +240,7 @@
- [Florin-Popescu](https://github.com/Florin-Popescu)
- [m0g3r](https://github.com/m0g3r)
- [martin-77](https://github.com/martin-77)
- [Oggeb1](https://github.com/Oggeb1)
# Emby Contributors
+1 -1
View File
@@ -9,7 +9,7 @@
<PackageVersion Include="AutoFixture.Xunit3" Version="4.19.0" />
<PackageVersion Include="AutoFixture" Version="4.18.1" />
<PackageVersion Include="BDInfo" Version="0.8.0" />
<PackageVersion Include="BitFaster.Caching" Version="2.6.0" />
<PackageVersion Include="BitFaster.Caching" Version="2.6.1" />
<PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.4.0-pre.1" />
<PackageVersion Include="BlurHashSharp" Version="1.4.0-pre.1" />
<PackageVersion Include="CommandLineParser" Version="2.9.1" />
@@ -44,7 +44,14 @@ namespace Emby.Naming.ExternalFiles
}
var extension = Path.GetExtension(path.AsSpan());
if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
// .idx carries VobSub per-track language metadata. Recognize it here rather
// than adding it to NamingOptions.SubtitleFileExtensions, which also gates
// subtitle uploads/saves.
var isVobSubIndex = _type == DlnaProfileType.Subtitle && extension.Equals(".idx", StringComparison.OrdinalIgnoreCase);
if (!isVobSubIndex
&& !(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
&& !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))
&& !(_type == DlnaProfileType.Lyric && _namingOptions.LyricFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)))
{
+4 -2
View File
@@ -158,7 +158,9 @@ namespace Emby.Naming.TV
if (nextIndex >= name.Length
|| !"0123456789iIpP".Contains(name[nextIndex], StringComparison.Ordinal))
{
if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num))
// A range cannot end before it starts, so a lower number belongs to the episode title rather than to a range.
if (int.TryParse(endingNumberGroup.ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture, out num)
&& num >= result.EpisodeNumber)
{
result.EndingEpisodeNumber = num;
}
@@ -226,7 +228,7 @@ namespace Emby.Naming.TV
info.SeriesName = result.SeriesName;
}
if (!info.EndingEpisodeNumber.HasValue && info.EpisodeNumber.HasValue)
if (!info.EndingEpisodeNumber.HasValue && result.EndingEpisodeNumber >= info.EpisodeNumber)
{
info.EndingEpisodeNumber = result.EndingEpisodeNumber;
}
+32 -3
View File
@@ -185,6 +185,13 @@ namespace Emby.Server.Implementations.Dto
allCollectionFolders = _libraryManager.GetUserRootFolder().Children.OfType<Folder>().ToList();
}
// Batch-fetch by-name item counts to avoid N+1 queries
Dictionary<Guid, ItemCounts>? itemCountsBatch = null;
if (options.ContainsField(ItemFields.ItemCounts))
{
itemCountsBatch = GetItemCountsBatch(accessibleItems, user);
}
// Batch-fetch child counts for all folders to avoid N+1 queries
Dictionary<Guid, int>? childCountBatch = null;
if (options.ContainsField(ItemFields.ChildCount))
@@ -293,7 +300,7 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.ItemCounts))
{
SetItemByNameInfo(dto, user);
SetItemByNameInfo(dto, user, itemCountsBatch);
}
returnItems[index] = dto;
@@ -518,14 +525,36 @@ namespace Emby.Server.Implementations.Dto
return dto;
}
private void SetItemByNameInfo(BaseItemDto dto, User? user)
private Dictionary<Guid, ItemCounts> GetItemCountsBatch(IReadOnlyList<BaseItem> items, User? user)
{
var result = new Dictionary<Guid, ItemCounts>();
foreach (var group in items.GroupBy(item => item.GetBaseItemKind()))
{
if (!_relatedItemKinds.TryGetValue(group.Key, out var relatedItemKinds))
{
continue;
}
var ids = group.Select(item => item.Id).ToArray();
foreach (var (id, counts) in _libraryManager.GetItemCountsForNameItems(group.Key, ids, relatedItemKinds, user))
{
result[id] = counts;
}
}
return result;
}
private void SetItemByNameInfo(BaseItemDto dto, User? user, IReadOnlyDictionary<Guid, ItemCounts>? prefetchedCounts = null)
{
if (!_relatedItemKinds.TryGetValue(dto.Type, out var relatedItemKinds))
{
return;
}
var counts = _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user);
var counts = prefetchedCounts?.GetValueOrDefault(dto.Id)
?? _libraryManager.GetItemCountsForNameItem(dto.Type, dto.Id, relatedItemKinds, user);
dto.AlbumCount = counts.AlbumCount;
dto.ArtistCount = counts.ArtistCount;
@@ -27,6 +27,11 @@ namespace Emby.Server.Implementations.EntryPoints;
/// </summary>
public sealed class LibraryChangedNotifier : IHostedService, IDisposable
{
// A batch holds a live reference to every item it names, so it has to stay small enough that a
// library scan - which changes items faster than any batch window closes - cannot grow it without
// bound. Reached only by a scan; interactive use closes a batch on the window long before this.
internal const int MaxBatchSize = 2000;
private readonly ILibraryManager _libraryManager;
private readonly IServerConfigurationManager _configurationManager;
private readonly IProviderManager _providerManager;
@@ -35,11 +40,11 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
private readonly ILogger<LibraryChangedNotifier> _logger;
private readonly Lock _libraryChangedSyncLock = new();
private readonly List<Folder> _foldersAddedTo = new();
private readonly List<Folder> _foldersRemovedFrom = new();
private readonly List<BaseItem> _itemsAdded = new();
private readonly List<BaseItem> _itemsRemoved = new();
private readonly List<BaseItem> _itemsUpdated = new();
private readonly Dictionary<Guid, Folder> _foldersAddedTo = [];
private readonly Dictionary<Guid, Folder> _foldersRemovedFrom = [];
private readonly Dictionary<Guid, BaseItem> _itemsAdded = [];
private readonly Dictionary<Guid, BaseItem> _itemsRemoved = [];
private readonly Dictionary<Guid, BaseItem> _itemsUpdated = [];
private readonly ConcurrentDictionary<Guid, DateTime> _lastProgressMessageTimes = new();
private Timer? _libraryUpdateTimer;
@@ -173,7 +178,7 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
private void OnLibraryItemRemoved(object? sender, ItemChangeEventArgs e)
=> OnLibraryChange(e.Item, e.Parent, _itemsRemoved, _foldersRemovedFrom);
private void OnLibraryChange(BaseItem item, BaseItem parent, List<BaseItem> itemsList, List<Folder>? foldersList)
private void OnLibraryChange(BaseItem item, BaseItem parent, Dictionary<Guid, BaseItem> itemsList, Dictionary<Guid, Folder>? foldersList)
{
if (!FilterItem(item))
{
@@ -182,23 +187,28 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
lock (_libraryChangedSyncLock)
{
var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
// The window runs from the first change of a batch and is never extended. Extending it on
// every change would keep a library scan's batch open for the whole scan, and the batch
// holds the items it names alive, so it would grow to the size of the library.
if (_libraryUpdateTimer is null)
{
var updateDuration = TimeSpan.FromSeconds(_configurationManager.Configuration.LibraryUpdateDuration);
_libraryUpdateTimer = new Timer(LibraryUpdateTimerCallback, null, updateDuration, Timeout.InfiniteTimeSpan);
}
else
{
_libraryUpdateTimer.Change(updateDuration, Timeout.InfiniteTimeSpan);
}
if (foldersList is not null && parent is Folder folder)
{
foldersList.Add(folder);
foldersList[folder.Id] = folder;
}
itemsList.Add(item);
itemsList[item.Id] = item;
// A window long enough to cover a burst still has to give way once the batch is large
// enough to be worth sending on its own.
if (_itemsAdded.Count + _itemsRemoved.Count + _itemsUpdated.Count >= MaxBatchSize)
{
_libraryUpdateTimer.Change(TimeSpan.Zero, Timeout.InfiniteTimeSpan);
}
}
}
@@ -211,22 +221,16 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
List<BaseItem> itemsRemoved;
lock (_libraryChangedSyncLock)
{
// Remove dupes in case some were saved multiple times
foldersAddedTo = _foldersAddedTo
.DistinctBy(x => x.Id)
.ToList();
foldersRemovedFrom = _foldersRemovedFrom
.DistinctBy(x => x.Id)
.ToList();
foldersAddedTo = _foldersAddedTo.Values.ToList();
foldersRemovedFrom = _foldersRemovedFrom.Values.ToList();
itemsUpdated = _itemsUpdated
.Where(i => !_itemsAdded.Contains(i))
.DistinctBy(x => x.Id)
.Where(e => !_itemsAdded.ContainsKey(e.Key))
.Select(e => e.Value)
.ToList();
itemsAdded = _itemsAdded.ToList();
itemsRemoved = _itemsRemoved.ToList();
itemsAdded = _itemsAdded.Values.ToList();
itemsRemoved = _itemsRemoved.Values.ToList();
if (_libraryUpdateTimer is not null)
{
@@ -241,6 +245,15 @@ public sealed class LibraryChangedNotifier : IHostedService, IDisposable
_foldersRemovedFrom.Clear();
}
if (itemsAdded.Count == 0
&& itemsUpdated.Count == 0
&& itemsRemoved.Count == 0
&& foldersAddedTo.Count == 0
&& foldersRemovedFrom.Count == 0)
{
return;
}
await SendChangeNotifications(itemsAdded, itemsUpdated, itemsRemoved, foldersAddedTo, foldersRemovedFrom, CancellationToken.None).ConfigureAwait(false);
}
@@ -18,15 +18,17 @@ namespace Emby.Server.Implementations.EntryPoints
public sealed class UserDataChangeNotifier : IHostedService, IDisposable
{
private const int UpdateDuration = 500;
internal const int MaxBatchSize = 2000;
private readonly ISessionManager _sessionManager;
private readonly IUserDataManager _userDataManager;
private readonly IUserManager _userManager;
private readonly Dictionary<Guid, List<BaseItem>> _changedItems = new();
private readonly Dictionary<Guid, Dictionary<Guid, BaseItem>> _changedItems = [];
private readonly Lock _syncLock = new();
private Timer? _updateTimer;
private int _changedItemCount;
/// <summary>
/// Initializes a new instance of the <see cref="UserDataChangeNotifier"/> class.
@@ -69,50 +71,64 @@ namespace Emby.Server.Implementations.EntryPoints
lock (_syncLock)
{
if (_updateTimer is null)
{
_updateTimer = new Timer(
UpdateTimerCallback,
null,
UpdateDuration,
Timeout.Infinite);
}
else
{
_updateTimer.Change(UpdateDuration, Timeout.Infinite);
}
// The window runs from the first change of a batch and is never extended, so a stream
// of changes that never pauses - a library scan - still closes its batches instead of
// holding every item it touched alive until the stream stops.
_updateTimer ??= new Timer(
UpdateTimerCallback,
null,
UpdateDuration,
Timeout.Infinite);
if (!_changedItems.TryGetValue(e.UserId, out List<BaseItem>? keys))
if (!_changedItems.TryGetValue(e.UserId, out Dictionary<Guid, BaseItem>? keys))
{
keys = new List<BaseItem>();
keys = [];
_changedItems[e.UserId] = keys;
}
keys.Add(e.Item);
var baseItem = e.Item;
// Go up one level for indicators
if (baseItem is not null)
{
Track(keys, baseItem);
var parent = baseItem.GetOwner() ?? baseItem.GetParent();
if (parent is not null)
{
keys.Add(parent);
Track(keys, parent);
}
}
// A window long enough to cover a burst still has to give way once the batch is
// large enough to be worth sending on its own.
if (_changedItemCount >= MaxBatchSize)
{
_updateTimer.Change(0, Timeout.Infinite);
}
}
}
private void Track(Dictionary<Guid, BaseItem> keys, BaseItem item)
{
var before = keys.Count;
keys[item.Id] = item;
if (keys.Count != before)
{
_changedItemCount++;
}
}
private async void UpdateTimerCallback(object? state)
{
List<KeyValuePair<Guid, List<BaseItem>>> changes;
List<KeyValuePair<Guid, Dictionary<Guid, BaseItem>>> changes;
lock (_syncLock)
{
// Remove dupes in case some were saved multiple times
changes = _changedItems.ToList();
_changedItems.Clear();
_changedItemCount = 0;
if (_updateTimer is not null)
{
@@ -121,17 +137,22 @@ namespace Emby.Server.Implementations.EntryPoints
}
}
if (changes.Count == 0)
{
return;
}
foreach (var (userId, changedItems) in changes)
{
await _sessionManager.SendMessageToUserSessions(
[userId],
SessionMessageType.UserDataChanged,
() => GetUserDataChangeInfo(userId, changedItems),
() => GetUserDataChangeInfo(userId, changedItems.Values),
default).ConfigureAwait(false);
}
}
private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, List<BaseItem> changedItems)
private UserDataChangeInfo GetUserDataChangeInfo(Guid userId, IEnumerable<BaseItem> changedItems)
{
var user = _userManager.GetUserById(userId)
?? throw new ArgumentException("Invalid user ID", nameof(userId));
@@ -140,7 +161,6 @@ namespace Emby.Server.Implementations.EntryPoints
{
UserId = userId,
UserDataList = changedItems
.DistinctBy(x => x.Id)
.Select(i =>
{
var dto = _userDataManager.GetUserDataDto(i, user);
@@ -8,6 +8,7 @@ using Emby.Server.Implementations.Library;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
@@ -21,6 +22,7 @@ namespace Emby.Server.Implementations.IO
private readonly ILibraryManager _libraryManager;
private readonly IServerConfigurationManager _configurationManager;
private readonly IFileSystem _fileSystem;
private readonly IDirectoryService _directoryService;
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
/// <summary>
@@ -47,6 +49,7 @@ namespace Emby.Server.Implementations.IO
/// <param name="libraryManager">The library manager.</param>
/// <param name="configurationManager">The configuration manager.</param>
/// <param name="fileSystem">The filesystem.</param>
/// <param name="directoryService">The directory service.</param>
/// <param name="appLifetime">The <see cref="IHostApplicationLifetime"/>.</param>
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
public LibraryMonitor(
@@ -54,6 +57,7 @@ namespace Emby.Server.Implementations.IO
ILibraryManager libraryManager,
IServerConfigurationManager configurationManager,
IFileSystem fileSystem,
IDirectoryService directoryService,
IHostApplicationLifetime appLifetime,
DotIgnoreIgnoreRule dotIgnoreIgnoreRule)
{
@@ -61,6 +65,7 @@ namespace Emby.Server.Implementations.IO
_logger = logger;
_configurationManager = configurationManager;
_fileSystem = fileSystem;
_directoryService = directoryService;
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
appLifetime.ApplicationStarted.Register(Start);
@@ -363,6 +368,8 @@ namespace Emby.Server.Implementations.IO
return;
}
_directoryService.Invalidate(path);
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too
foreach (var i in _tempIgnoredPaths.Keys)
{
@@ -3,6 +3,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
@@ -15,7 +16,6 @@ using Emby.Naming.Common;
using Emby.Naming.TV;
using Emby.Naming.Video;
using Emby.Server.Implementations.Library.Resolvers;
using Emby.Server.Implementations.Library.Validators;
using Emby.Server.Implementations.Playlists;
using Emby.Server.Implementations.ScheduledTasks.Tasks;
using Emby.Server.Implementations.Sorting;
@@ -35,7 +35,6 @@ using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Playlists;
using MediaBrowser.Controller.Providers;
@@ -75,7 +74,6 @@ namespace Emby.Server.Implementations.Library
private readonly Lazy<IProviderManager> _providerManagerFactory;
private readonly Lazy<IUserViewManager> _userViewManagerFactory;
private readonly IServerApplicationHost _appHost;
private readonly IMediaEncoder _mediaEncoder;
private readonly IFileSystem _fileSystem;
private readonly IItemRepository _itemRepository;
private readonly IItemPersistenceService _persistenceService;
@@ -88,6 +86,7 @@ namespace Emby.Server.Implementations.Library
private readonly ExtraResolver _extraResolver;
private readonly IPathManager _pathManager;
private readonly ILocalizationManager _localization;
private readonly IDirectoryService _directoryService;
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
private readonly IMediaStreamRepository _mediaStreamRepository;
@@ -122,7 +121,6 @@ namespace Emby.Server.Implementations.Library
/// <param name="fileSystem">The file system.</param>
/// <param name="providerManagerFactory">The provider manager.</param>
/// <param name="userViewManagerFactory">The user view manager.</param>
/// <param name="mediaEncoder">The media encoder.</param>
/// <param name="itemRepository">The item repository.</param>
/// <param name="persistenceService">The item persistence service.</param>
/// <param name="nextUpService">The next up service.</param>
@@ -148,7 +146,6 @@ namespace Emby.Server.Implementations.Library
IFileSystem fileSystem,
Lazy<IProviderManager> providerManagerFactory,
Lazy<IUserViewManager> userViewManagerFactory,
IMediaEncoder mediaEncoder,
IItemRepository itemRepository,
IItemPersistenceService persistenceService,
INextUpService nextUpService,
@@ -174,7 +171,6 @@ namespace Emby.Server.Implementations.Library
_fileSystem = fileSystem;
_providerManagerFactory = providerManagerFactory;
_userViewManagerFactory = userViewManagerFactory;
_mediaEncoder = mediaEncoder;
_itemRepository = itemRepository;
_persistenceService = persistenceService;
_nextUpService = nextUpService;
@@ -189,6 +185,7 @@ namespace Emby.Server.Implementations.Library
_pathManager = pathManager;
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
_localization = localization;
_directoryService = directoryService;
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
@@ -1209,6 +1206,12 @@ namespace Emby.Server.Implementations.Library
.FirstOrDefault();
}
/// <inheritdoc />
public Guid GetPersonId(string name)
{
return GetItemByNameId<Person>(Person.GetPath(name));
}
/// <inheritdoc />
public Person? GetPerson(string name)
{
@@ -1222,6 +1225,33 @@ namespace Emby.Server.Implementations.Library
return null;
}
/// <inheritdoc />
public Person GetOrCreatePerson(string name)
{
var existing = GetPerson(name);
if (existing is not null)
{
return existing;
}
var path = Person.GetPath(name);
var info = Directory.CreateDirectory(path);
var item = new Person
{
Name = name,
Id = GetItemByNameId<Person>(path),
DateCreated = info.CreationTimeUtc,
DateModified = info.LastWriteTimeUtc,
Path = path
};
item.PresentationUniqueKey = item.CreatePresentationUniqueKey();
CreateItem(item, null);
return item;
}
/// <summary>
/// Gets the studio.
/// </summary>
@@ -1354,15 +1384,6 @@ namespace Emby.Server.Implementations.Library
return GetNewItemIdInternal(path, typeof(T), forceCaseInsensitiveId);
}
/// <inheritdoc />
public Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
// Ensure the location is available.
Directory.CreateDirectory(_configurationManager.ApplicationPaths.PeoplePath);
return new PeopleValidator(this, _logger, _fileSystem).ValidatePeople(cancellationToken, progress);
}
/// <summary>
/// Reloads the root media folder.
/// </summary>
@@ -1489,6 +1510,10 @@ namespace Emby.Server.Implementations.Library
var numComplete = 0;
var numTasks = tasks.Count;
_logger.LogInformation("Running {TaskCount} post-scan task(s)", numTasks);
var phaseStart = Stopwatch.GetTimestamp();
foreach (var task in tasks)
{
// Prevent access to modified closure
@@ -1506,20 +1531,45 @@ namespace Emby.Server.Implementations.Library
progress.Report(innerPercent);
});
_logger.LogDebug("Running post-scan task {0}", task.GetType().Name);
var taskName = task.GetType().Name;
var taskStart = Stopwatch.GetTimestamp();
_logger.LogInformation(
"Running post-scan task {TaskNumber}/{TaskCount}: {TaskName}",
currentNumComplete + 1,
numTasks,
taskName);
try
{
await task.Run(innerProgress, cancellationToken).ConfigureAwait(false);
var elapsed = Stopwatch.GetElapsedTime(taskStart);
_logger.LogInformation(
"Post-scan task {TaskName} completed after {Minutes} minute(s) and {Seconds} seconds",
taskName,
Math.Truncate(elapsed.TotalMinutes),
elapsed.Seconds);
}
catch (OperationCanceledException)
{
_logger.LogInformation("Post-scan task cancelled: {0}", task.GetType().Name);
var elapsed = Stopwatch.GetElapsedTime(taskStart);
_logger.LogInformation(
"Post-scan task {TaskName} cancelled after {Minutes} minute(s) and {Seconds} seconds",
taskName,
Math.Truncate(elapsed.TotalMinutes),
elapsed.Seconds);
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error running post-scan task");
var elapsed = Stopwatch.GetElapsedTime(taskStart);
_logger.LogError(
ex,
"Post-scan task {TaskName} failed after {Minutes} minute(s) and {Seconds} seconds",
taskName,
Math.Truncate(elapsed.TotalMinutes),
elapsed.Seconds);
}
numComplete++;
@@ -1528,6 +1578,12 @@ namespace Emby.Server.Implementations.Library
progress.Report(percent * 100);
}
var phaseElapsed = Stopwatch.GetElapsedTime(phaseStart);
_logger.LogInformation(
"All post-scan tasks completed after {Minutes} minute(s) and {Seconds} seconds",
Math.Truncate(phaseElapsed.TotalMinutes),
phaseElapsed.Seconds);
_persistenceService.UpdateInheritedValues();
progress.Report(100);
@@ -1745,6 +1801,18 @@ namespace Emby.Server.Implementations.Library
return _countService.GetItemCountsForNameItem(kind, id, relatedItemKinds, query);
}
/// <inheritdoc/>
public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user)
{
var query = new InternalItemsQuery(user);
if (user is not null)
{
AddUserToQuery(query, user);
}
return _countService.GetItemCountsForNameItems(kind, ids, relatedItemKinds, query);
}
public Dictionary<Guid, int> GetChildCountBatch(IReadOnlyList<Guid> parentIds, User? user)
{
return _countService.GetChildCountBatch(parentIds, user);
@@ -3720,6 +3788,10 @@ namespace Emby.Server.Implementations.Library
AddMediaPathInternal(name, path, false);
}
}
// The libraries root was listed before this folder existed, so drop that listing:
// anything still reading it resolves the library set without the new folder.
_directoryService.Invalidate(virtualFolderPath);
}
finally
{
@@ -3746,27 +3818,14 @@ namespace Emby.Server.Implementations.Library
var itemUpdateType = ItemUpdateType.MetadataDownload;
var saveEntity = false;
var createEntity = false;
var personEntity = GetPerson(person.Name);
if (personEntity is null)
{
try
{
var path = Person.GetPath(person.Name);
var info = Directory.CreateDirectory(path);
personEntity = new Person()
{
Name = person.Name,
Id = GetItemByNameId<Person>(path),
DateCreated = info.CreationTimeUtc,
DateModified = info.LastWriteTimeUtc,
Path = path
};
personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey();
personEntity = GetOrCreatePerson(person.Name);
saveEntity = true;
createEntity = true;
}
catch (Exception ex)
{
@@ -3800,11 +3859,6 @@ namespace Emby.Server.Implementations.Library
if (saveEntity)
{
if (createEntity)
{
CreateItems([personEntity], null, CancellationToken.None);
}
await RunMetadataSavers(personEntity, itemUpdateType).ConfigureAwait(false);
personEntity.DateLastSaved = DateTime.UtcNow;
@@ -3920,6 +3974,7 @@ namespace Emby.Server.Implementations.Library
try
{
Directory.Delete(path, true);
_directoryService.Invalidate(path);
}
finally
{
@@ -3989,6 +4044,7 @@ namespace Emby.Server.Implementations.Library
if (!string.IsNullOrEmpty(shortcut))
{
_fileSystem.DeleteFile(shortcut);
_directoryService.Invalidate(shortcut);
}
var libraryOptions = CollectionFolder.GetLibraryOptions(virtualFolderPath);
@@ -4032,6 +4088,7 @@ namespace Emby.Server.Implementations.Library
}
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
_directoryService.Invalidate(lnk);
RemoveContentTypeOverrides(path);
}
@@ -260,7 +260,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
}
var candidateRows = await context.ItemValuesMap.AsNoTracking()
.Where(m => m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
.Where(m => !m.Item.PrimaryVersionId.HasValue && m.ItemValue.Type == valueType && allKeys.Contains(m.ItemValue.CleanValue))
.Select(m => new { m.ItemId, Key = m.ItemValue.CleanValue })
.ToListAsync(cancellationToken).ConfigureAwait(false);
@@ -276,6 +276,7 @@ public sealed class MovieSimilarItemsProvider : ILocalSimilarItemsProvider<Movie
if (personSourceRows.Count > 0)
{
var personCandidateRows = await context.PeopleBaseItemMap.AsNoTracking()
.Where(m => !m.Item.PrimaryVersionId.HasValue)
.Where(m => context.PeopleBaseItemMap
.Where(s => sourceIds.Contains(s.ItemId) && _scoredPersonTypes.Contains(s.People.PersonType))
.Select(s => s.PeopleId)
@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
@@ -61,6 +62,9 @@ public class ArtistsValidator
var count = names.Count;
var refreshed = 0;
var liveIds = new HashSet<Guid>();
var unresolved = 0;
foreach (var name in names)
{
try
@@ -73,13 +77,20 @@ public class ArtistsValidator
// Fall back to GetArtist if not found (creates new item if needed)
item ??= _libraryManager.GetArtist(name);
var isNew = !existingArtistIds.Contains(item.Id);
var neverRefreshed = item.DateLastRefreshed == default;
if (isNew || neverRefreshed)
// A name with no item is nothing to refresh, and nothing to keep alive either.
if (item is not null)
{
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
refreshed++;
liveIds.Add(item.Id);
var isNew = !existingArtistIds.Contains(item.Id);
var neverRefreshed = item.DateLastRefreshed == default;
if (isNew || neverRefreshed)
{
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
refreshed++;
}
}
}
catch (OperationCanceledException)
@@ -88,6 +99,7 @@ public class ArtistsValidator
}
catch (Exception ex)
{
unresolved++;
_logger.LogError(ex, "Error refreshing {ArtistName}", name);
}
@@ -101,13 +113,26 @@ public class ArtistsValidator
_logger.LogInformation("Refreshed metadata for {RefreshedCount} new artists out of {TotalCount} total", refreshed, count);
// Every name that threw is a name whose artist is missing from the live set, and deleting against
// a live set with holes in it deletes artists the library still refers to. Leave the sweep to a
// run that got a clean read of them.
if (unresolved > 0)
{
_logger.LogWarning(
"Not removing dead artists: {Count} of {TotalCount} names could not be resolved this run",
unresolved,
count);
progress.Report(100);
return;
}
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.MusicArtist],
IsDeadArtist = true,
IsLocked = false
}).Cast<MusicArtist>()
.Where(item => item.IsAccessedByName)
}).OfType<MusicArtist>()
.Where(item => item.IsAccessedByName && !liveIds.Contains(item.Id))
.ToList();
foreach (var item in deadEntities)
@@ -1,12 +1,12 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library.Validators;
@@ -17,112 +17,143 @@ namespace Emby.Server.Implementations.Library.Validators;
public class PeopleValidator
{
/// <summary>
/// The _library manager.
/// The library manager.
/// </summary>
private readonly ILibraryManager _libraryManager;
/// <summary>
/// The _logger.
/// The logger.
/// </summary>
private readonly ILogger _logger;
private readonly IFileSystem _fileSystem;
private readonly ILogger<PeopleValidator> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="PeopleValidator" /> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
/// <param name="logger">The logger.</param>
/// <param name="fileSystem">The file system.</param>
public PeopleValidator(ILibraryManager libraryManager, ILogger logger, IFileSystem fileSystem)
public PeopleValidator(ILibraryManager libraryManager, ILogger<PeopleValidator> logger)
{
_libraryManager = libraryManager;
_logger = logger;
_fileSystem = fileSystem;
}
/// <summary>
/// Validates the people.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <param name="progress">The progress.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
public async Task ValidatePeople(CancellationToken cancellationToken, IProgress<double> progress)
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
{
// Before the refresh below walks them: a credit no item maps to any more stands for nothing,
// and while it is there the person it names cannot reach the dead-person sweep either.
var numOrphaned = _libraryManager.DeleteOrphanedCredits();
if (numOrphaned > 0)
{
_logger.LogDebug("Deleted {Amount} credits no item maps to", numOrphaned);
_logger.LogInformation("Deleted {Amount} credits no item maps to", numOrphaned);
}
var people = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
var names = _libraryManager.GetPeopleNames(new InternalPeopleQuery());
var existingPersonIds = _libraryManager.GetItemIds(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Person]
}).ToHashSet();
var (newNames, deadIds) = PartitionCreditsByPersonId(names, _libraryManager.GetPersonId, existingPersonIds);
var numComplete = 0;
var count = names.Count;
var refreshed = 0;
var numPeople = people.Count;
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
_logger.LogDebug("Will refresh {Amount} people", numPeople);
foreach (var person in people)
foreach (var name in names)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var item = _libraryManager.GetPerson(person);
if (item is null)
var item = _libraryManager.GetOrCreatePerson(name);
var isNew = !existingPersonIds.Contains(item.Id);
var neverRefreshed = item.DateLastRefreshed == default;
if (isNew || neverRefreshed)
{
_logger.LogWarning("Failed to get person: {Name}", person);
continue;
await item.RefreshMetadata(cancellationToken).ConfigureAwait(false);
refreshed++;
}
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
{
ImageRefreshMode = MetadataRefreshMode.ValidationOnly,
MetadataRefreshMode = MetadataRefreshMode.ValidationOnly
};
await item.RefreshMetadata(options, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// Don't clutter the log
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error validating IBN entry {Person}", person);
_logger.LogError(ex, "Error refreshing {PersonName}", name);
}
// Update progress
numComplete++;
double percent = numComplete;
percent /= numPeople;
percent /= count;
percent *= 100;
subProgress.Report(100 * percent);
progress.Report(percent);
}
var deadEntities = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Person],
IsDeadPerson = true,
IsLocked = false
});
_logger.LogInformation(
"Refreshed metadata for {RefreshedCount} people out of {TotalCount} total, {NewCount} of which had no item yet",
refreshed,
count,
newNames.Count);
subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
// A person somebody locked is theirs, not ours, however little the library still credits them.
var deadEntities = deadIds
.Select(_libraryManager.GetItemById)
.OfType<Person>()
.Where(item => !item.IsLocked)
.ToList();
var i = 0;
foreach (var item in deadEntities.Chunk(500))
foreach (var item in deadEntities)
{
_libraryManager.DeleteItemsUnsafeFast(item, true);
subProgress.Report(100f / deadEntities.Count * (i++ * 100));
_logger.LogInformation("Deleting dead {ItemType} {ItemId} {ItemName}", item.GetType().Name, item.Id.ToString("N", CultureInfo.InvariantCulture), item.Name);
}
_libraryManager.DeleteItemsUnsafeFast(deadEntities, deleteSourceFiles: true);
progress.Report(100);
}
_logger.LogInformation("People validation complete, deleted {Orphaned} orphaned credits", numOrphaned);
/// <summary>
/// Splits the person items into the ones a credit still calls for and the ones nothing does.
/// </summary>
/// <param name="creditNames">Every name credited on an item, from the people table.</param>
/// <param name="getPersonId">Maps a credit name to the id its person item has.</param>
/// <param name="existingPersonIds">The ids of the person items that exist.</param>
/// <returns>The credits needing an item, and the ids of the items nothing credits.</returns>
internal static (List<string> NewNames, List<Guid> DeadIds) PartitionCreditsByPersonId(
IReadOnlyList<string> creditNames,
Func<string, Guid> getPersonId,
IReadOnlySet<Guid> existingPersonIds)
{
ArgumentNullException.ThrowIfNull(creditNames);
ArgumentNullException.ThrowIfNull(getPersonId);
ArgumentNullException.ThrowIfNull(existingPersonIds);
var newNames = new List<string>();
var liveIds = new HashSet<Guid>();
foreach (var name in creditNames)
{
var personId = getPersonId(name);
// Distinct credit names can normalize onto one id; only the first of them needs an item.
if (liveIds.Add(personId) && !existingPersonIds.Contains(personId))
{
newNames.Add(name);
}
}
var deadIds = existingPersonIds.Where(id => !liveIds.Contains(id)).ToList();
return (newNames, deadIds);
}
}
@@ -112,5 +112,11 @@
"NameExtraInterview": "Інтэрв'ю",
"NameExtraNumbered": "{0} {1}",
"NameExtraScene": "Сцэна",
"NameExtraTrailer": "Трэйлер"
"NameExtraTrailer": "Трэйлер",
"NameExtraBehindTheScenes": "За кулісамі",
"NameExtraClip": "Кліп",
"NameExtraFeaturette": "Кароткаметражка",
"NameExtraSample": "Прыклад",
"NameExtraShort": "Кароткаметражка",
"NameExtraThemeSong": "Тэматычная песня"
}
@@ -106,5 +106,17 @@
"TaskMoveTrickplayImages": "Migracija lokacije slike Trickplay",
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke trik-igara prema postavkama biblioteke.",
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
"CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana."
"CleanupUserDataTaskDescription": "Čisti sve korisničke podatke (stanje praćenja, status omiljenog itd.) sa medija koji više nije prisutan najmanje 90 dana.",
"NameExtraBehindTheScenes": "Iza kulisa",
"NameExtraClip": "Isječak",
"NameExtraDeletedScene": "Izbrišana scena",
"NameExtraFeaturette": "Kratki prilog",
"NameExtraInterview": "Intervju",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Uzorak",
"NameExtraScene": "Scena",
"NameExtraShort": "Kratko",
"NameExtraThemeSong": "Tema",
"NameExtraThemeVideo": "Tematski video",
"NameExtraTrailer": "Najava"
}
@@ -108,5 +108,18 @@
"CleanupUserDataTaskDescription": "Καθαρίζει όλα τα δεδομένα χρήστη (κατάσταση παρακολούθησης, κατάσταση αγαπημένων κ.λπ.) από πολυμέσα που δεν υπάρχουν πλέον για τουλάχιστον 90 ημέρες.",
"CleanupUserDataTask": "Εργασία εκκαθάρισης δεδομένων χρήστη",
"LyricDownloadFailureFromForItem": "Αποτυχία λήψης στίχων από {0} για {1}",
"Original": "Πρωτότυπο"
"Original": "Πρωτότυπο",
"NameExtraBehindTheScenes": "Πίσω από τις Σκηνές",
"NameExtraDeletedScene": "Διεγραμμένη Σκηνή",
"NameExtraFeaturette": "Πρόσθετα βίντεο",
"NameExtraInterview": "Συνέντευξη",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Δείγμα",
"NameExtraScene": "Σκηνή",
"NameExtraShort": "Βίντεο μικρού μήκους",
"NameExtraThemeSong": "Θεματικό Τραγούδι",
"NameExtraThemeVideo": "Θεματικό Βίντεο",
"NameExtraTrailer": "τρέιλερ ταινίας",
"NameExtraUnknown": "Πρόσθετα",
"NameExtraClip": "Απόσπασμα"
}
@@ -113,5 +113,12 @@
"NameExtraClip": "Klippi",
"NameExtraDeletedScene": "Poistettu Kohtaus",
"NameExtraFeaturette": "Lyhytelokuva",
"NameExtraInterview": "Haastattelu"
"NameExtraInterview": "Haastattelu",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Näyte",
"NameExtraScene": "Kohtaus",
"NameExtraShort": "Lyhytfilmi",
"NameExtraThemeSong": "Tunnusmusiikki",
"NameExtraThemeVideo": "Tunnusvideo",
"NameExtraTrailer": "Traileri"
}
@@ -7,8 +7,8 @@
"AppDeviceValues": "App: {0}, Eind: {1}",
"Books": "Bøkur",
"ChapterNameValue": "Kapittul {0}",
"Favorites": "Yndis",
"Folders": "Mappur",
"Favorites": "Yndislista",
"Folders": "Skjáttur",
"Forced": "Kravt",
"FailedLoginAttemptWithUserName": "Miseydnað innritanarroynd frá {0}",
"HeaderFavoriteEpisodes": "Yndispartar",
@@ -104,7 +104,7 @@
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsend",
"NameExtraShort": "Stuttfilmur",
"NameExtraThemeSong": "Eyðkennislag",
"NameExtraTrailer": "Forfilmur",
"NameExtraTrailer": "Brellbiti",
"NameExtraInterview": "Samrøða",
"NameExtraBehindTheScenes": "Aftanfyri leiktjøldini",
"NameExtraClip": "Klipp",
@@ -112,14 +112,14 @@
"NameExtraFeaturette": "Stuttur heimildarfilmur",
"TaskAudioNormalization": "Ljóðjavnan",
"TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan.",
"NameExtraSample": "Kut",
"NameExtraSample": "Sýnislutur",
"TaskRefreshTrickplayImages": "Framleið Trickplay-myndir",
"TaskRefreshTrickplayImagesDescription": "Framleiðir trickplay-myndir fyri kykmyndir í søvnunm har tað er virkt.",
"TaskMoveTrickplayImages": "Flyt Trickplay-myndagoymslustað",
"TaskMoveTrickplayImagesDescription": "Flytur verandi trickplay-fílur sambært savnsstillingunum.",
"NameExtraThemeVideo": "Eyðkenniskykmynd",
"NameExtraDeletedScene": "Úrtikin mynd",
"NameExtraScene": "Mynd (scena)",
"NameExtraScene": "Mynd",
"NameExtraUnknown": "Eykatilfar",
"Original": "Upprunalig(t/ur)"
}
@@ -108,5 +108,17 @@
"CleanupUserDataTask": "Tasc glantacháin sonraí úsáideora",
"CleanupUserDataTaskDescription": "Glanann sé gach sonraí úsáideora (stádas faire, stádas is fearr leat srl.) ó mheáin nach bhfuil i láthair a thuilleadh ar feadh 90 lá ar a laghad.",
"Original": "Bunaidh",
"LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}"
"LyricDownloadFailureFromForItem": "Theip ar liricí a íoslódáil ó {0} do {1}",
"NameExtraBehindTheScenes": "Taobh thiar de na Radhairc",
"NameExtraClip": "Gearrthóg",
"NameExtraDeletedScene": "Radharc Scriosta",
"NameExtraFeaturette": "Mionghné",
"NameExtraInterview": "Agallamh",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Sampla",
"NameExtraScene": "Radharc",
"NameExtraShort": "Gearr",
"NameExtraThemeSong": "Amhrán Téama",
"NameExtraThemeVideo": "Físeán Téama",
"NameExtraTrailer": "Leantóir"
}
@@ -108,5 +108,17 @@
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
"CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.",
"Original": "Original",
"LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo"
"LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo",
"NameExtraBehindTheScenes": "Iza kulisa",
"NameExtraClip": "Klip",
"NameExtraDeletedScene": "Obrisana Scena",
"NameExtraFeaturette": "Promotivni video",
"NameExtraInterview": "Intervju",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Uzorak",
"NameExtraScene": "Scena",
"NameExtraShort": "Kratki film",
"NameExtraThemeSong": "Glavna Pjesma",
"NameExtraThemeVideo": "Tema videa",
"NameExtraTrailer": "Trailer"
}
@@ -108,5 +108,17 @@
"LyricDownloadFailureFromForItem": "Feeler beim Download vun de Songtexter vun {0} fir {1}",
"Original": "Original",
"CleanupUserDataTask": "Aufgab fir Berengege vu Benotzerdaten",
"CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn."
"CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn.",
"NameExtraBehindTheScenes": "Hannert de Kulissen",
"NameExtraClip": "Clip",
"NameExtraDeletedScene": "Geläschte Scène",
"NameExtraFeaturette": "Featurette",
"NameExtraInterview": "Interview",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Beispill",
"NameExtraScene": "Scène",
"NameExtraShort": "Kuerzfilm",
"NameExtraThemeSong": "Theme-Lidd",
"NameExtraThemeVideo": "Theme-Video",
"NameExtraTrailer": "Bande-Annonce"
}
@@ -100,14 +100,14 @@
"TaskAudioNormalization": "Garso normalizavimas",
"TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.",
"TaskExtractMediaSegments": "Medijos segmentų nuskaitymas",
"TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus",
"TaskDownloadMissingLyrics": "Atsisiųsti trūkstamus dainų tekstus",
"TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.",
"TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą",
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.",
"TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius",
"TaskDownloadMissingLyricsDescription": "Atsisiųsti dainų tekstus",
"CleanupUserDataTask": "Naudotojo duomenų valymo užduotis",
"CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamą būseną ir t. t.).",
"LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos žodžių iš {0}, skirto {1}",
"LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos teksto iš {0}, skirto {1}",
"NameExtraBehindTheScenes": "Užkulisiuose",
"NameExtraClip": "Klipas",
"NameExtraDeletedScene": "Ištrinta scena",
@@ -108,5 +108,17 @@
"CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums",
"CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.",
"Original": "Oriģināls",
"LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}"
"LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}",
"NameExtraBehindTheScenes": "Aiz kadra",
"NameExtraClip": "Klips",
"NameExtraDeletedScene": "Izdzēsta aina",
"NameExtraFeaturette": "Īsfilma",
"NameExtraInterview": "Intervija",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Paraugs",
"NameExtraScene": "Aina",
"NameExtraShort": "Īsfilma",
"NameExtraThemeSong": "Motīvu dziesma",
"NameExtraThemeVideo": "Tēmas video",
"NameExtraTrailer": "Treileris"
}
@@ -106,5 +106,15 @@
"TaskMoveTrickplayImagesDescription": "Flytter eksisterende Trickplay-filer i henhold til biblioteksinstillingene.",
"TaskExtractMediaSegmentsDescription": "Trekker ut eller henter mediasegmenter fra plugins som støtter MediaSegment.",
"CleanupUserDataTaskDescription": "Sletter all brukerdata (avspillings-status, favoritter osv.) fra innhold som har vært utilgjengelig i minst 90 dager.",
"CleanupUserDataTask": "Oppgave for opprydding av brukerdata"
"CleanupUserDataTask": "Oppgave for opprydding av brukerdata",
"NameExtraBehindTheScenes": "Bak kulissene",
"NameExtraDeletedScene": "Slettet scene",
"NameExtraFeaturette": "Presentasjonsfilm",
"NameExtraInterview": "Intervju",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Prøve",
"NameExtraScene": "Scene",
"NameExtraThemeSong": "Tema-låt",
"NameExtraThemeVideo": "Tema-video",
"NameExtraTrailer": "Trailer"
}
@@ -111,5 +111,14 @@
"Original": "Original",
"NameExtraBehindTheScenes": "În culise",
"NameExtraClip": "Clip",
"NameExtraDeletedScene": "Scenă ștearsă"
"NameExtraDeletedScene": "Scenă ștearsă",
"NameExtraFeaturette": "Material bonus",
"NameExtraInterview": "Interviu",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Monstră",
"NameExtraScene": "Scenă",
"NameExtraShort": "Scurt",
"NameExtraThemeSong": "Audio de Fundal",
"NameExtraThemeVideo": "Video de Fundal",
"NameExtraTrailer": "Trailer"
}
@@ -91,7 +91,7 @@
"Default": "Predvolené",
"TaskOptimizeDatabaseDescription": "Zmenší databázu a odstráni prázdne miesto. Spustenie tejto úlohy po skenovaní knižnice alebo po iných zmenách zahŕňajúcich úpravy databáze môže zlepšiť výkon.",
"TaskOptimizeDatabase": "Optimalizovať databázu",
"TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS zoznamov prehrávania. Táto úloha môže trvať dlhšiu dobu.",
"TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z videosúborov na vytvorenie presnejších HLS zoznamov. Táto úloha môže trvať dlhší čas.",
"TaskKeyframeExtractor": "Extraktor kľúčových snímkov",
"External": "Externé",
"HearingImpaired": "Sluchovo postihnutí",
@@ -108,5 +108,17 @@
"CleanupUserDataTask": "Čiščenje uporabniških podatkov",
"CleanupUserDataTaskDescription": "Izbriše vse uporabniške podatke (stanje ogleda, priljubljene itd.) za vsebine, ki že več kot 90 dni niso na voljo.",
"LyricDownloadFailureFromForItem": "Besedila ni bilo mogoče prenesti iz {0} za {1}",
"Original": "Original"
"Original": "Original",
"NameExtraBehindTheScenes": "V zakulisju",
"NameExtraClip": "Klip",
"NameExtraDeletedScene": "Izbrisan prizor",
"NameExtraFeaturette": "Kratek dokumentarec o izdelavi filma",
"NameExtraInterview": "Intervju",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Vzorec",
"NameExtraScene": "Prizor",
"NameExtraShort": "Kratki film",
"NameExtraThemeSong": "Tematska Pesem",
"NameExtraThemeVideo": "Tematski Video",
"NameExtraTrailer": "Napovednik"
}
@@ -22,20 +22,20 @@
"NewVersionIsAvailable": "เวอร์ชันใหม่ของเซิร์ฟเวอร์ Jellyfin พร้อมให้ดาวน์โหลดแล้ว",
"NameSeasonUnknown": "ไม่ทราบซีซัน",
"NameSeasonNumber": "ซีซัน {0}",
"NameInstallFailed": "การติดตั้ง {0} ล้มเหลว",
"NameInstallFailed": "ติดตั้ง {0} ไม่สำเร็จ",
"MusicVideos": "มิวสิควิดีโอ",
"Music": "ดนตรี",
"Music": "เพลง",
"Movies": "ภาพยนตร์",
"MixedContent": "เนื้อหาผสม",
"Latest": "ล่าสุด",
"LabelRunningTimeValue": "ผ่านไปแล้ว: {0}",
"LabelIpAddressValue": "ที่อยู่ IP: {0}",
"Inherit": "สืบทอด",
"HomeVideos": "โฮมวิดีโอ",
"HeaderNextUp": "ถัดไป",
"HeaderLiveTV": "ทีวีสด",
"HeaderFavoriteShows": "รายการที่ชื่นชอบ",
"HeaderFavoriteEpisodes": "ตอนที่ชื่นชอบ",
"MixedContent": "เนื้อหาหลากหลายประเภท",
"Latest": "มาใหม่ล่าสุด",
"LabelRunningTimeValue": "ความยาว: {0}",
"LabelIpAddressValue": "หมายเลข IP: {0}",
"Inherit": "ใช้ค่าเริ่มต้น",
"HomeVideos": "วิดีโอส่วนตัว",
"HeaderNextUp": "รายการถัดไป",
"HeaderLiveTV": "ทีวีถ่ายทอดสด",
"HeaderFavoriteShows": "รายการที่ชอบ",
"HeaderFavoriteEpisodes": "ตอนที่ชอบ",
"HeaderContinueWatching": "ดูต่อ",
"Genres": "ประเภท",
"Folders": "โฟลเดอร์",
@@ -107,6 +107,19 @@
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
"CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน",
"LyricDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1}",
"Original": "ต้นฉบับ"
"LyricDownloadFailureFromForItem": "ดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1} ไม่สำเร็จ",
"Original": "ต้นฉบับ",
"NameExtraBehindTheScenes": "เบื้องหลังการถ่ายทำ",
"NameExtraClip": "คลิปวิดีโอ",
"NameExtraDeletedScene": "ฉากที่ถูกตัดออก",
"NameExtraFeaturette": "คลิปสั้นพิเศษ",
"NameExtraInterview": "บทสัมภาษณ์",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "ตัวอย่าง",
"NameExtraScene": "ฉาก",
"NameExtraShort": "ภาพยนตร์สั้น",
"NameExtraThemeSong": "เพลงประกอบ",
"NameExtraThemeVideo": "วิดีโอธีม",
"NameExtraTrailer": "ตัวอย่างภาพยนตร์",
"NameExtraUnknown": "เนื้อหาพิเศษ"
}
@@ -395,29 +395,11 @@ namespace Emby.Server.Implementations.Plugins
var url = new Uri(packageInfo.ImageUrl);
imagePath = Path.Join(path, url.Segments[^1]);
var fileStream = AsyncFile.OpenWrite(imagePath);
Stream? downloadStream = null;
try
// The catalog is refreshed on every dashboard visit and rewrites the manifest of
// every installed plugin, so only fetch an image that is actually missing.
if (!ImageExists(imagePath))
{
downloadStream = await HttpClientFactory
.CreateClient(NamedClient.Default)
.GetStreamAsync(url)
.ConfigureAwait(false);
await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false);
}
catch (HttpRequestException ex)
{
_logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath);
imagePath = string.Empty;
}
finally
{
await fileStream.DisposeAsync().ConfigureAwait(false);
if (downloadStream is not null)
{
await downloadStream.DisposeAsync().ConfigureAwait(false);
}
imagePath = await DownloadImage(url, imagePath).ConfigureAwait(false);
}
}
@@ -456,6 +438,67 @@ namespace Emby.Server.Implementations.Plugins
}
}
private static bool ImageExists(string imagePath)
{
var image = new FileInfo(imagePath);
// A previous download may have been interrupted, leaving an empty file behind.
return image.Exists && image.Length > 0;
}
private async Task<string> DownloadImage(Uri url, string imagePath)
{
// Download to a temporary file and move it into place, so that neither a failed download
// nor a concurrent one can be observed as a partially written image.
var tempPath = imagePath + "." + Path.GetRandomFileName();
try
{
var fileStream = AsyncFile.Create(tempPath);
Stream? downloadStream = null;
try
{
downloadStream = await HttpClientFactory
.CreateClient(NamedClient.Default)
.GetStreamAsync(url)
.ConfigureAwait(false);
await downloadStream.CopyToAsync(fileStream).ConfigureAwait(false);
}
finally
{
await fileStream.DisposeAsync().ConfigureAwait(false);
if (downloadStream is not null)
{
await downloadStream.DisposeAsync().ConfigureAwait(false);
}
}
File.Move(tempPath, imagePath, true);
return imagePath;
}
catch (Exception ex) when (ex is HttpRequestException or IOException or UnauthorizedAccessException)
{
_logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath);
TryDeleteFile(tempPath);
return string.Empty;
}
}
private void TryDeleteFile(string path)
{
try
{
File.Delete(path);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Unable to delete {Path}.", path);
}
}
/// <summary>
/// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the path.
/// If no file is found, no reconciliation occurs.
@@ -11,7 +11,7 @@ using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
/// <summary>
/// Optimizes Jellyfin's database by issuing a VACUUM command.
/// Optimizes Jellyfin's database by issuing VACUUM and ANALYZE commands.
/// </summary>
public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
{
@@ -82,7 +82,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
return;
}
_logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
_logger.LogInformation("Vacuuming and analyzing jellyfin.db...");
try
{
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.Library.Validators;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
@@ -29,6 +30,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
private readonly IDbContextFactory<JellyfinDbContext> _dbContextFactory;
private readonly IFileSystem _fileSystem;
private readonly ILogger<PeopleValidationTask> _logger;
private readonly ILogger<PeopleValidator> _validatorLogger;
private readonly IItemTypeLookup _itemTypeLookup;
/// <summary>
@@ -39,6 +41,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
/// <param name="dbContextFactory">Instance of the <see cref="IDbContextFactory{TContext}"/> interface.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param>
/// <param name="validatorLogger">Instance of the <see cref="ILogger{PeopleValidator}"/> interface.</param>
/// <param name="itemTypeLookup">Instance of the <see cref="IItemTypeLookup"/> interface.</param>
public PeopleValidationTask(
ILibraryManager libraryManager,
@@ -46,6 +49,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
IDbContextFactory<JellyfinDbContext> dbContextFactory,
IFileSystem fileSystem,
ILogger<PeopleValidationTask> logger,
ILogger<PeopleValidator> validatorLogger,
IItemTypeLookup itemTypeLookup)
{
_libraryManager = libraryManager;
@@ -53,6 +57,7 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
_dbContextFactory = dbContextFactory;
_fileSystem = fileSystem;
_logger = logger;
_validatorLogger = validatorLogger;
_itemTypeLookup = itemTypeLookup;
}
@@ -165,7 +170,9 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
// Phase 2: Validate people (33-66%). Runs after orphaned PeopleBaseItemMap entries are
// cleaned up above, so dead people are removed in a single pass instead of requiring a second run.
IProgress<double> validateProgress = new Progress<double>((val) => progress.Report((val / 3) + 33));
await _libraryManager.ValidatePeopleAsync(validateProgress, cancellationToken).ConfigureAwait(false);
await new PeopleValidator(_libraryManager, _validatorLogger)
.Run(validateProgress, cancellationToken)
.ConfigureAwait(false);
// Phase 3: Refresh images for people missing them (66-100%)
IProgress<double> refreshProgress = new Progress<double>((val) => progress.Report((val / 3) + 66));
@@ -107,6 +107,11 @@ public class PluginUpdateTask : IScheduledTask, IConfigurableScheduledTask
{
_logger.LogError(ex, "Error updating {Name}", package.Name);
}
catch (TimeoutException ex)
{
// One slow download must not abort the updates for the remaining plugins.
_logger.LogError(ex, "Error downloading {Name}", package.Name);
}
catch (InvalidDataException ex)
{
_logger.LogError(ex, "Error updating {Name}", package.Name);
+16 -2
View File
@@ -90,6 +90,18 @@ namespace Emby.Server.Implementations.SyncPlay
/// <value>The default ping.</value>
public long DefaultPing { get; } = 500;
/// <summary>
/// Gets the maximum ping, in milliseconds, accepted from a session.
/// </summary>
/// <remarks>
/// Pings are reported by clients and are scaled into the delays used to schedule playback,
/// so an unbounded value lets a single session push the whole group's resume point
/// arbitrarily far out, or overflow the arithmetic entirely. Anything above this is not a
/// usable measurement for synchronisation.
/// </remarks>
/// <value>The maximum ping.</value>
public long MaxPing { get; } = 10000;
/// <summary>
/// Gets the maximum time offset error accepted for dates reported by clients, in milliseconds.
/// </summary>
@@ -438,7 +450,7 @@ namespace Emby.Server.Implementations.SyncPlay
{
if (_participants.TryGetValue(session.Id, out GroupMember value))
{
value.Ping = ping;
value.Ping = Math.Clamp(ping, 0, MaxPing);
}
}
@@ -451,7 +463,9 @@ namespace Emby.Server.Implementations.SyncPlay
max = Math.Max(max, session.Ping);
}
return max;
// A group with no participants has no ping to report. Returning long.MinValue would
// overflow the callers that scale this value into ticks, so fall back to the default.
return max == long.MinValue ? DefaultPing : max;
}
/// <inheritdoc />
@@ -181,8 +181,8 @@ namespace Emby.Server.Implementations.SyncPlay
{
if (existingGroup.GroupId.Equals(request.GroupId))
{
// Restore session.
UpdateSessionsCounter(session.UserId, 1);
// Restore session. The session is already in the group and has already
// been counted, so the counter must not be incremented a second time.
group.SessionJoin(session, request, cancellationToken);
return;
}
@@ -332,8 +332,11 @@ namespace Emby.Server.Implementations.SyncPlay
// Group lock required as Group is not thread-safe.
lock (group)
{
// Make sure that session still belongs to this group.
if (_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup) && !checkGroup.GroupId.Equals(group.GroupId))
// Make sure that session still belongs to this group. The lookup can fail
// outright when the session left while this request was waiting on the group
// lock, which is exactly the case this re-check exists to catch.
if (!_sessionToGroupMap.TryGetValue(session.Id, out var checkGroup)
|| !checkGroup.GroupId.Equals(group.GroupId))
{
// Drop request.
return;
@@ -400,7 +403,7 @@ namespace Emby.Server.Implementations.SyncPlay
// Update sessions counter.
var newSessionsCounter = _activeUsers.AddOrUpdate(
userId,
1,
toAdd,
(_, sessionsCounter) => sessionsCounter + toAdd);
// Should never happen.
@@ -11,7 +11,6 @@ using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Events;
using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Configuration;
@@ -34,6 +33,9 @@ namespace Emby.Server.Implementations.Updates
public class InstallationManager : IInstallationManager
{
private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']);
// Budget for the whole package download. The response headers are already bounded by the
// HttpClient timeout; this covers reading the package body, which can be large and slow.
private static readonly TimeSpan PackageDownloadTimeout = TimeSpan.FromMinutes(10);
/// <summary>
/// The logger.
@@ -82,8 +84,8 @@ namespace Emby.Server.Implementations.Updates
IServerConfigurationManager config,
IPluginManager pluginManager)
{
_currentInstallations = new List<(InstallationInfo, CancellationTokenSource)>();
_completedInstallationsInternal = new ConcurrentBag<InstallationInfo>();
_currentInstallations = [];
_completedInstallationsInternal = [];
_logger = logger;
_applicationHost = appHost;
@@ -341,8 +343,9 @@ namespace Emby.Server.Implementations.Updates
_applicationHost.NotifyPendingRestart();
}
catch (OperationCanceledException)
catch (OperationCanceledException) when (linkedToken.IsCancellationRequested)
{
// Only an actually cancelled token is a cancellation.
lock (_currentInstallationsLock)
{
_currentInstallations.Remove(tuple);
@@ -356,7 +359,7 @@ namespace Emby.Server.Implementations.Updates
}
catch (Exception ex)
{
_logger.LogError(ex, "Package installation failed");
_logger.LogError(ex, "Package installation failed: {Name} {Version}", package.Name, package.Version);
lock (_currentInstallationsLock)
{
@@ -546,12 +549,36 @@ namespace Emby.Server.Implementations.Updates
throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory.");
}
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using (stream.ConfigureAwait(false))
// ResponseHeadersRead keeps the body out of the HttpClient timeout, which otherwise covers
// the whole download; the package gets the longer budget below instead.
using var downloadTokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
downloadTokenSource.CancelAfter(PackageDownloadTimeout);
var downloadToken = downloadTokenSource.Token;
var buffer = new MemoryStream();
await using (buffer.ConfigureAwait(false))
{
try
{
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(new Uri(package.SourceUrl), HttpCompletionOption.ResponseHeadersRead, downloadToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
// The package is read twice, for the checksum and for the extraction, so it has
// to be buffered: the response stream is not seekable.
await response.Content.CopyToAsync(buffer, downloadToken).ConfigureAwait(false);
}
catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested)
{
// Either our budget above or the HttpClient timeout ran out.
throw new TimeoutException(
$"Downloading the package {package.Name} {package.Version} from {package.SourceUrl} timed out.",
ex);
}
buffer.Position = 0;
Stream stream = buffer;
// CA5351: Do Not Use Broken Cryptographic Algorithms
#pragma warning disable CA5351
cancellationToken.ThrowIfCancellationRequested();
@@ -16,6 +16,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Authorization;
@@ -34,6 +35,7 @@ public class LibraryStructureController : BaseJellyfinApiController
private readonly IServerApplicationPaths _appPaths;
private readonly ILibraryManager _libraryManager;
private readonly ILibraryMonitor _libraryMonitor;
private readonly IDirectoryService _directoryService;
/// <summary>
/// Initializes a new instance of the <see cref="LibraryStructureController"/> class.
@@ -41,14 +43,17 @@ public class LibraryStructureController : BaseJellyfinApiController
/// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param>
/// <param name="libraryManager">Instance of <see cref="ILibraryManager"/> interface.</param>
/// <param name="libraryMonitor">Instance of <see cref="ILibraryMonitor"/> interface.</param>
/// <param name="directoryService">Instance of <see cref="IDirectoryService"/> interface.</param>
public LibraryStructureController(
IServerConfigurationManager serverConfigurationManager,
ILibraryManager libraryManager,
ILibraryMonitor libraryMonitor)
ILibraryMonitor libraryMonitor,
IDirectoryService directoryService)
{
_appPaths = serverConfigurationManager.ApplicationPaths;
_libraryManager = libraryManager;
_libraryMonitor = libraryMonitor;
_directoryService = directoryService;
}
/// <summary>
@@ -178,11 +183,11 @@ public class LibraryStructureController : BaseJellyfinApiController
var tempPath = Path.Combine(
rootFolderPath,
Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture));
Directory.Move(currentPath, tempPath);
_directoryService.Move(currentPath, tempPath);
currentPath = tempPath;
}
Directory.Move(currentPath, newPath);
_directoryService.Move(currentPath, newPath);
}
finally
{
+30 -1
View File
@@ -3,7 +3,9 @@ using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;
using Jellyfin.Extensions;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Common.Updates;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Model.Updates;
@@ -23,16 +25,22 @@ public class PackageController : BaseJellyfinApiController
{
private readonly IInstallationManager _installationManager;
private readonly IServerConfigurationManager _serverConfigurationManager;
private readonly IPluginManager _pluginManager;
/// <summary>
/// Initializes a new instance of the <see cref="PackageController"/> class.
/// </summary>
/// <param name="installationManager">Instance of the <see cref="IInstallationManager"/> interface.</param>
/// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param>
public PackageController(IInstallationManager installationManager, IServerConfigurationManager serverConfigurationManager)
/// <param name="pluginManager">Instance of the <see cref="IPluginManager"/> interface.</param>
public PackageController(
IInstallationManager installationManager,
IServerConfigurationManager serverConfigurationManager,
IPluginManager pluginManager)
{
_installationManager = installationManager;
_serverConfigurationManager = serverConfigurationManager;
_pluginManager = pluginManager;
}
/// <summary>
@@ -48,6 +56,13 @@ public class PackageController : BaseJellyfinApiController
[FromRoute, Required] string name,
[FromQuery] Guid? assemblyGuid)
{
// Plugins bundled with the server are not published to any repository, so querying
// the configured repositories for them can only ever fail, and does so slowly.
if (IsBundledPlugin(name, assemblyGuid))
{
return NotFound();
}
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
var result = _installationManager.FilterPackages(
packages,
@@ -96,6 +111,11 @@ public class PackageController : BaseJellyfinApiController
[FromQuery] string? version,
[FromQuery] string? repositoryUrl)
{
if (IsBundledPlugin(name, assemblyGuid))
{
return NotFound();
}
var packages = await _installationManager.GetAvailablePackages().ConfigureAwait(false);
if (!string.IsNullOrEmpty(repositoryUrl))
{
@@ -161,4 +181,13 @@ public class PackageController : BaseJellyfinApiController
_serverConfigurationManager.SaveConfiguration();
return NoContent();
}
private bool IsBundledPlugin(string name, Guid? assemblyGuid)
{
var plugin = assemblyGuid is Guid id && !id.IsEmpty()
? _pluginManager.GetPlugin(id)
: _pluginManager.Plugins.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
return plugin?.Instance?.CanUninstall == false;
}
}
@@ -557,7 +557,7 @@ public class SubtitleController : BaseJellyfinApiController
if (!string.IsNullOrEmpty(fallbackFontPath))
{
var fontFile = _fileSystem.GetFiles(fallbackFontPath)
.First(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
.FirstOrDefault(i => string.Equals(i.Name, name, StringComparison.OrdinalIgnoreCase));
var fileSize = fontFile?.Length;
if (fontFile is not null && fileSize is not null && fileSize > 0)
+1
View File
@@ -458,6 +458,7 @@ public class DynamicHlsHelper
{
case VideoRangeType.HLG:
case VideoRangeType.DOVIWithHLG:
case VideoRangeType.DOVIInvalid when string.Equals(state.VideoStream.ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase):
builder.Append(",VIDEO-RANGE=HLG");
break;
default:
+3 -2
View File
@@ -61,8 +61,9 @@ public enum VideoRangeType
DOVIWithELHDR10Plus,
/// <summary>
/// Dolby Vision with invalid configuration. e.g. Profile 8 compat id 6.
/// When using this range, the server would assume the video is still HDR10 after removing the Dolby Vision metadata.
/// Dolby Vision with invalid configuration, e.g. Profile 8 compat id 6 or inconsistent base-layer color metadata.
/// The base layer is classified as HDR only when its transfer characteristics signal PQ or HLG.
/// Otherwise, it is classified as SDR.
/// </summary>
DOVIInvalid,
@@ -121,20 +121,22 @@ public sealed partial class BaseItemRepository
{
using var context = _dbProvider.CreateDbContext();
var query = context.ItemValuesMap
.AsNoTracking()
.Where(e => itemValueTypes.Any(w => w == e.ItemValue.Type));
var maps = context.ItemValuesMap.AsNoTracking();
if (withItemTypes.Count > 0)
{
query = query.Where(e => withItemTypes.Contains(e.Item.Type));
maps = maps.Where(e => withItemTypes.Contains(e.Item.Type));
}
if (excludeItemTypes.Count > 0)
{
query = query.Where(e => !excludeItemTypes.Contains(e.Item.Type));
maps = maps.Where(e => !excludeItemTypes.Contains(e.Item.Type));
}
return query.Select(e => e.ItemValue)
return context.ItemValues
.AsNoTracking()
.WhereOneOrMany(itemValueTypes, e => e.Type)
.Where(e => maps.Any(m => m.ItemValueId == e.ItemValueId))
.Select(e => new { e.CleanValue, e.Value })
.GroupBy(e => e.CleanValue)
.Select(g => g.Min(v => v.Value)!)
.ToArray();
@@ -317,14 +319,7 @@ public sealed partial class BaseItemRepository
.WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue);
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
var movieTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum];
var musicArtistTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
var musicVideoTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicVideo];
var programTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.LiveTvProgram];
var audioTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Audio];
var trailerTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Trailer];
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
// Instead, start from ItemValueMaps and join with BaseItems.
@@ -333,9 +328,9 @@ public sealed partial class BaseItemRepository
scopedItems,
ivm => ivm.ItemId,
e => e.Id,
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId })
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type, e.SeriesId, e.Id })
.GroupBy(x => new { x.CleanName, x.Type, x.SeriesId })
.Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Count() })
.Select(g => new { g.Key.CleanName, g.Key.Type, g.Key.SeriesId, Count = g.Select(x => x.Id).Distinct().Count() })
.ToList();
// Only studios and genres pass down from a series to its episodes; an artist credit does not.
@@ -357,46 +352,10 @@ public sealed partial class BaseItemRepository
foreach (var group in rawCounts.GroupBy(x => x.CleanName))
{
var counts = new ItemCounts();
foreach (var row in group)
{
if (row.Type == seriesTypeName)
{
counts.SeriesCount += row.Count;
}
else if (row.Type == movieTypeName)
{
counts.MovieCount += row.Count;
}
else if (row.Type == musicAlbumTypeName)
{
counts.AlbumCount += row.Count;
}
else if (row.Type == musicArtistTypeName)
{
counts.ArtistCount += row.Count;
}
else if (row.Type == musicVideoTypeName)
{
counts.MusicVideoCount += row.Count;
}
else if (row.Type == programTypeName)
{
counts.ProgramCount += row.Count;
}
else if (row.Type == audioTypeName)
{
counts.SongCount += row.Count;
}
else if (row.Type == trailerTypeName)
{
counts.TrailerCount += row.Count;
}
}
var counts = ItemCountBuilder.Build(_itemTypeLookup, group.Select(row => (row.Type, row.Count)));
// Episodes are counted separately: the value is usually only written on the series.
counts.EpisodeCount = episodeCounts.GetValueOrDefault(group.Key);
counts.ItemCount = counts.TotalItemCount();
ItemCountBuilder.SetEpisodeCount(counts, episodeCounts.GetValueOrDefault(group.Key));
countsByCleanName[group.Key] = counts;
}
@@ -405,7 +364,9 @@ public sealed partial class BaseItemRepository
{
if (!countsByCleanName.ContainsKey(cleanName))
{
countsByCleanName[cleanName] = new ItemCounts { EpisodeCount = episodeCount, ItemCount = episodeCount };
var counts = new ItemCounts();
ItemCountBuilder.SetEpisodeCount(counts, episodeCount);
countsByCleanName[cleanName] = counts;
}
}
@@ -626,18 +626,26 @@ public sealed partial class BaseItemRepository
.ToArray();
var tags = context.ItemValuesMap
.Where(ivm => ivm.ItemValue.Type == ItemValueType.Tags)
.Where(ivm => matchingItemIds.Contains(ivm.ItemId))
.Select(ivm => ivm.ItemValue)
.Join(
context.ItemValues,
ivm => ivm.ItemValueId,
iv => iv.ItemValueId,
(ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value })
.Where(iv => iv.Type == ItemValueType.Tags)
.Where(iv => matchingItemIds.Contains(iv.ItemId))
.GroupBy(iv => iv.CleanValue)
.Select(g => g.Min(iv => iv.Value))
.OrderBy(t => t)
.ToArray();
var genres = context.ItemValuesMap
.Where(ivm => ivm.ItemValue.Type == ItemValueType.Genre)
.Where(ivm => matchingItemIds.Contains(ivm.ItemId))
.Select(ivm => ivm.ItemValue)
.Join(
context.ItemValues,
ivm => ivm.ItemValueId,
iv => iv.ItemValueId,
(ivm, iv) => new { ivm.ItemId, iv.Type, iv.CleanValue, iv.Value })
.Where(iv => iv.Type == ItemValueType.Genre)
.Where(iv => matchingItemIds.Contains(iv.ItemId))
.GroupBy(iv => iv.CleanValue)
.Select(g => g.Min(iv => iv.Value))
.OrderBy(g => g)
@@ -38,22 +38,32 @@ public sealed partial class BaseItemRepository
// Shared by the isPlayed filter and the IsPlayed/IsUnplayed ordering so the two cannot disagree.
private Expression<Func<BaseItemEntity, bool>> BuildIsPlayedFilter(JellyfinDbContext context, User user)
{
var userId = user.Id;
// Folders (Series, Seasons, BoxSets, albums, ...) carry no played state of their own and count
// as played once no descendant is left unplayed.
var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user)
.Where(BuildLeafIsPlayedFilter(context, user.Id).Not());
// Leaf items carry their own played state.
return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
.Or(IsFolderFilter.Not().And(BuildLeafIsPlayedFilter(context, user.Id)));
}
private static Expression<Func<BaseItemEntity, bool>> BuildLeafIsPlayedFilter(JellyfinDbContext context, Guid userId)
{
var playedItemIds = context.UserData
.Where(ud => ud.UserId == userId && ud.Played)
.Select(ud => ud.ItemId);
// Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no
// descendant is left unplayed, matching what the DTO reports for them. This has to key off
// the item itself rather than off the requested item types: tag and collection listings mix
// folders and leaf items in a single query.
var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, user)
.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
// The primaries of every version group holding a played row, whichever version carries it.
var playedGroupIds = context.BaseItems
.Where(v => v.PrimaryVersionId != null
&& context.UserData.Any(ud => ud.UserId == userId
&& ud.Played
&& (ud.ItemId == v.Id || ud.ItemId == v.PrimaryVersionId)))
.Select(v => v.PrimaryVersionId!.Value);
return IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
.Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
return e => playedItemIds.Contains(e.Id)
|| playedGroupIds.Contains(e.Id)
|| (e.PrimaryVersionId != null && playedGroupIds.Contains(e.PrimaryVersionId.Value));
}
// "und" is the language filters' stand-in for a track that declares no language at all.
@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Model.Dto;
namespace Jellyfin.Server.Implementations.Item;
/// <summary>
/// Turns per-type counts into an <see cref="ItemCounts"/>.
/// </summary>
internal static class ItemCountBuilder
{
/// <summary>
/// Builds the counts of one by-name item.
/// </summary>
/// <param name="itemTypeLookup">The item type lookup.</param>
/// <param name="counts">The counted items, by type name. A type may repeat.</param>
/// <returns>The counts.</returns>
public static ItemCounts Build(IItemTypeLookup itemTypeLookup, IEnumerable<(string Type, int Count)> counts)
{
ArgumentNullException.ThrowIfNull(itemTypeLookup);
ArgumentNullException.ThrowIfNull(counts);
var lookup = itemTypeLookup.BaseItemKindNames;
var result = new ItemCounts();
foreach (var (type, count) in counts)
{
// Accumulated rather than assigned: a caller may group by something finer than the
// type and hand the same type over more than once.
if (string.Equals(type, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal))
{
result.AlbumCount += count;
}
else if (string.Equals(type, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal))
{
result.ArtistCount += count;
}
else if (string.Equals(type, lookup[BaseItemKind.Episode], StringComparison.Ordinal))
{
result.EpisodeCount += count;
}
else if (string.Equals(type, lookup[BaseItemKind.Movie], StringComparison.Ordinal))
{
result.MovieCount += count;
}
else if (string.Equals(type, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal))
{
result.MusicVideoCount += count;
}
else if (string.Equals(type, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal))
{
result.ProgramCount += count;
}
else if (string.Equals(type, lookup[BaseItemKind.Series], StringComparison.Ordinal))
{
result.SeriesCount += count;
}
else if (string.Equals(type, lookup[BaseItemKind.Audio], StringComparison.Ordinal))
{
result.SongCount += count;
}
else if (string.Equals(type, lookup[BaseItemKind.Trailer], StringComparison.Ordinal))
{
result.TrailerCount += count;
}
else if (string.Equals(type, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal))
{
result.BoxSetCount += count;
}
else if (string.Equals(type, lookup[BaseItemKind.Book], StringComparison.Ordinal))
{
result.BookCount += count;
}
}
result.ItemCount = result.TotalItemCount();
return result;
}
/// <summary>
/// Replaces the episode count, which both by-name paths decide separately from the other
/// types because a genre or studio is usually written on the series rather than its episodes.
/// </summary>
/// <param name="counts">The counts to update.</param>
/// <param name="episodeCount">The episode count.</param>
public static void SetEpisodeCount(ItemCounts counts, int episodeCount)
{
ArgumentNullException.ThrowIfNull(counts);
counts.EpisodeCount = episodeCount;
counts.ItemCount = counts.TotalItemCount();
}
}
@@ -7,6 +7,7 @@ using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Persistence;
@@ -125,178 +126,286 @@ public class ItemCountService : IItemCountService
/// <inheritdoc />
public ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter)
{
using var context = _dbProvider.CreateDbContext();
return GetItemCountsForNameItems(kind, [id], relatedItemKinds, accessFilter)[id];
}
var item = context.BaseItems.AsNoTracking()
.Where(e => e.Id == id)
.Select(e => new { e.Name, e.CleanName })
.FirstOrDefault();
if (item is null)
private static ItemValueType[] GetItemValueTypes(BaseItemKind kind)
=> kind switch
{
return new ItemCounts();
BaseItemKind.MusicArtist => [ItemValueType.Artist, ItemValueType.AlbumArtist],
BaseItemKind.Genre or BaseItemKind.MusicGenre => [ItemValueType.Genre],
BaseItemKind.Studio => [ItemValueType.Studios],
_ => []
};
/// <inheritdoc />
public Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter)
{
ArgumentNullException.ThrowIfNull(ids);
ArgumentNullException.ThrowIfNull(relatedItemKinds);
ArgumentNullException.ThrowIfNull(accessFilter);
var result = new Dictionary<Guid, ItemCounts>();
if (ids.Count == 0)
{
return result;
}
IQueryable<BaseItemEntity> baseQuery;
switch (kind)
{
case BaseItemKind.Person:
baseQuery = ItemsById(context, context.PeopleBaseItemMap
.AsNoTracking()
.Where(m => m.People.Name == item.Name)
.Select(m => m.ItemId));
break;
case BaseItemKind.MusicArtist:
baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist))
.Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Genre:
case BaseItemKind.MusicGenre:
baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& ivm.ItemValue.Type == ItemValueType.Genre)
.Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Studio:
baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& ivm.ItemValue.Type == ItemValueType.Studios)
.Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Year:
if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
{
baseQuery = context.BaseItems
.AsNoTracking()
.Where(e => e.ProductionYear == year);
}
else
{
return new ItemCounts();
}
using var context = _dbProvider.CreateDbContext();
break;
default:
return new ItemCounts();
var idsArray = ids as Guid[] ?? ids.ToArray();
var nameItems = context.BaseItems.AsNoTracking()
.WhereOneOrMany(idsArray, e => e.Id)
.Select(e => new NameItem(e.Id, e.Name, e.CleanName))
.ToArray();
foreach (var id in ids)
{
result[id] = new ItemCounts();
}
if (nameItems.Length == 0)
{
return result;
}
var typeNames = relatedItemKinds.Select(k => _itemTypeLookup.BaseItemKindNames[k]).ToArray();
baseQuery = baseQuery.Where(e => typeNames.Contains(e.Type));
var related = _queryHelpers.ApplyAccessFiltering(
context,
context.BaseItems.AsNoTracking().Where(e => typeNames.Contains(e.Type)),
accessFilter);
baseQuery = _queryHelpers.ApplyAccessFiltering(context, baseQuery, accessFilter);
var counts = baseQuery
.GroupBy(x => x.Type)
.Select(x => new { x.Key, Count = x.Count() })
.ToArray();
var lookup = _itemTypeLookup.BaseItemKindNames;
var result = new ItemCounts();
var totalCount = 0;
foreach (var count in counts)
var valueTypes = GetItemValueTypes(kind);
if (valueTypes.Length > 0)
{
totalCount += count.Count;
if (string.Equals(count.Key, lookup[BaseItemKind.MusicAlbum], StringComparison.Ordinal))
{
result.AlbumCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.MusicArtist], StringComparison.Ordinal))
{
result.ArtistCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Episode], StringComparison.Ordinal))
{
result.EpisodeCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Movie], StringComparison.Ordinal))
{
result.MovieCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.MusicVideo], StringComparison.Ordinal))
{
result.MusicVideoCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.LiveTvProgram], StringComparison.Ordinal))
{
result.ProgramCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Series], StringComparison.Ordinal))
{
result.SeriesCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Audio], StringComparison.Ordinal))
{
result.SongCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Trailer], StringComparison.Ordinal))
{
result.TrailerCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.BoxSet], StringComparison.Ordinal))
{
result.BoxSetCount = count.Count;
}
else if (string.Equals(count.Key, lookup[BaseItemKind.Book], StringComparison.Ordinal))
{
result.BookCount = count.Count;
}
CountByItemValue(context, related, kind, relatedItemKinds, valueTypes, nameItems, result);
}
if (kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre
&& relatedItemKinds.Contains(BaseItemKind.Episode)
&& relatedItemKinds.Contains(BaseItemKind.Series))
else if (kind == BaseItemKind.Person)
{
var rolledUpEpisodeCount = CountEpisodesOfTaggedSeries(context, baseQuery, accessFilter, out var directEpisodeCount);
totalCount += rolledUpEpisodeCount - result.EpisodeCount + directEpisodeCount;
result.EpisodeCount = rolledUpEpisodeCount + directEpisodeCount;
CountByPersonName(context, related, nameItems, result);
}
else if (kind == BaseItemKind.Year)
{
CountByProductionYear(related, nameItems, result);
}
result.ItemCount = totalCount;
return result;
}
private int CountEpisodesOfTaggedSeries(
private void CountByItemValue(
JellyfinDbContext context,
IQueryable<BaseItemEntity> taggedItems,
InternalItemsQuery accessFilter,
out int unrelatedEpisodeCount)
IQueryable<BaseItemEntity> related,
BaseItemKind kind,
BaseItemKind[] relatedItemKinds,
ItemValueType[] valueTypes,
NameItem[] nameItems,
Dictionary<Guid, ItemCounts> result)
{
var cleanNames = nameItems
.Select(n => n.CleanName)
.OfType<string>()
.Distinct(StringComparer.Ordinal)
.ToArray();
if (cleanNames.Length == 0)
{
return;
}
var grouped = context.ItemValuesMap.AsNoTracking()
.Where(ivm => valueTypes.Contains(ivm.ItemValue.Type))
.WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue)
.Join(related, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Type, e.Id })
.GroupBy(x => new { x.CleanValue, x.Type })
.Select(g => new { g.Key.CleanValue, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() })
.ToArray();
var byCleanName = grouped
.GroupBy(g => g.CleanValue, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal);
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
var episodeRollUp = RollsUpEpisodes(kind, relatedItemKinds)
&& Array.Exists(grouped, g => string.Equals(g.Type, seriesTypeName, StringComparison.Ordinal))
? CountEpisodesOfTaggedSeriesByCleanName(context, related, valueTypes, cleanNames)
: null;
foreach (var nameItem in nameItems)
{
if (nameItem.CleanName is null || !byCleanName.TryGetValue(nameItem.CleanName, out var counts))
{
continue;
}
var itemCounts = ItemCountBuilder.Build(_itemTypeLookup, counts);
if (episodeRollUp is not null)
{
var rollUp = episodeRollUp.GetValueOrDefault(nameItem.CleanName);
// Episodes of a tagged series count towards it even when untagged themselves, and
// a tagged episode of a tagged series must not be counted a second time.
var directEpisodeCount = itemCounts.EpisodeCount - rollUp.TaggedEpisodesOfTaggedSeries;
ItemCountBuilder.SetEpisodeCount(itemCounts, rollUp.EpisodesOfTaggedSeries + directEpisodeCount);
}
result[nameItem.Id] = itemCounts;
}
}
private void CountByPersonName(
JellyfinDbContext context,
IQueryable<BaseItemEntity> related,
NameItem[] nameItems,
Dictionary<Guid, ItemCounts> result)
{
var names = nameItems
.Select(n => n.Name)
.OfType<string>()
.Distinct(StringComparer.Ordinal)
.ToArray();
if (names.Length == 0)
{
return;
}
var grouped = context.PeopleBaseItemMap.AsNoTracking()
.WhereOneOrMany(names, m => m.People.Name)
.Join(related, m => m.ItemId, e => e.Id, (m, e) => new { m.People.Name, e.Type, e.Id })
.GroupBy(x => new { x.Name, x.Type })
// A person can be credited on one item more than once, in different roles.
.Select(g => new { g.Key.Name, g.Key.Type, Count = g.Select(x => x.Id).Distinct().Count() })
.ToArray();
ApplyGroupedCounts(nameItems, n => n.Name, grouped.Select(g => (g.Name, g.Type, g.Count)), result);
}
private void CountByProductionYear(
IQueryable<BaseItemEntity> related,
NameItem[] nameItems,
Dictionary<Guid, ItemCounts> result)
{
var years = new List<int>();
foreach (var nameItem in nameItems)
{
if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)
&& !years.Contains(year))
{
years.Add(year);
}
}
if (years.Count == 0)
{
return;
}
// No join, so no row can be reached twice and a plain count is the distinct count.
var grouped = related
.Where(e => e.ProductionYear != null)
.WhereOneOrMany(years, e => e.ProductionYear!.Value)
.GroupBy(e => new { Year = e.ProductionYear!.Value, e.Type })
.Select(g => new { g.Key.Year, g.Key.Type, Count = g.Count() })
.ToArray();
var byYear = grouped
.GroupBy(g => g.Year)
.ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray());
foreach (var nameItem in nameItems)
{
if (int.TryParse(nameItem.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year)
&& byYear.TryGetValue(year, out var counts))
{
result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts);
}
}
}
private void ApplyGroupedCounts(
NameItem[] nameItems,
Func<NameItem, string?> keySelector,
IEnumerable<(string Key, string Type, int Count)> grouped,
Dictionary<Guid, ItemCounts> result)
{
var byKey = grouped
.GroupBy(g => g.Key, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Select(x => (x.Type, x.Count)).ToArray(), StringComparer.Ordinal);
foreach (var nameItem in nameItems)
{
var key = keySelector(nameItem);
if (key is not null && byKey.TryGetValue(key, out var counts))
{
result[nameItem.Id] = ItemCountBuilder.Build(_itemTypeLookup, counts);
}
}
}
private static bool RollsUpEpisodes(BaseItemKind kind, BaseItemKind[] relatedItemKinds)
=> kind is BaseItemKind.Studio or BaseItemKind.Genre or BaseItemKind.MusicGenre
&& relatedItemKinds.Contains(BaseItemKind.Episode)
&& relatedItemKinds.Contains(BaseItemKind.Series);
private Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)> CountEpisodesOfTaggedSeriesByCleanName(
JellyfinDbContext context,
IQueryable<BaseItemEntity> related,
ItemValueType[] valueTypes,
string[] cleanNames)
{
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
var episodeTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode];
var taggedSeriesIds = taggedItems.Where(e => e.Type == seriesTypeName).Select(e => e.Id);
unrelatedEpisodeCount = taggedItems.Count(e => e.Type == episodeTypeName
&& (e.SeriesId == null || !taggedSeriesIds.Contains(e.SeriesId.Value)));
var taggedValues = context.ItemValuesMap.AsNoTracking()
.Where(ivm => valueTypes.Contains(ivm.ItemValue.Type))
.WhereOneOrMany(cleanNames, ivm => ivm.ItemValue.CleanValue);
// Materialised so the episode count drives off IX_BaseItems_SeriesId.
var seriesIds = taggedItems
.Where(e => e.Type == seriesTypeName)
.Select(e => e.Id)
// The series carrying each clean name. Distinct, because one item can be mapped to the
// same clean name once per value type.
var taggedSeries = taggedValues
.Join(
related.Where(e => e.Type == seriesTypeName),
ivm => ivm.ItemId,
e => e.Id,
(ivm, e) => new { ivm.ItemValue.CleanValue, SeriesId = e.Id })
.Distinct();
var episodes = related.Where(e => e.Type == episodeTypeName && e.SeriesId != null);
var episodesOfTaggedSeries = taggedSeries
.Join(episodes, s => s.SeriesId, e => e.SeriesId!.Value, (s, e) => new { s.CleanValue, e.Id })
.GroupBy(x => x.CleanValue)
.Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() })
.ToArray();
if (seriesIds.Length == 0)
// Episodes that carry the clean name themselves *and* belong to a series carrying it. The
// roll-up already counts those, so they have to come off the directly tagged ones.
var taggedEpisodesOfTaggedSeries = taggedValues
.Join(episodes, ivm => ivm.ItemId, e => e.Id, (ivm, e) => new { ivm.ItemValue.CleanValue, e.Id, e.SeriesId })
.Join(
taggedSeries,
e => new { e.CleanValue, SeriesId = e.SeriesId!.Value },
s => new { s.CleanValue, s.SeriesId },
(e, s) => new { e.CleanValue, e.Id })
.GroupBy(x => x.CleanValue)
.Select(g => new { CleanValue = g.Key, Count = g.Select(x => x.Id).Distinct().Count() })
.ToArray();
var taggedLookup = taggedEpisodesOfTaggedSeries
.ToDictionary(x => x.CleanValue, x => x.Count, StringComparer.Ordinal);
// Every clean name in taggedLookup came from an episode of a tagged series, so it always
// has a row in episodesOfTaggedSeries too - no second merge pass is needed.
var result = new Dictionary<string, (int EpisodesOfTaggedSeries, int TaggedEpisodesOfTaggedSeries)>(StringComparer.Ordinal);
foreach (var entry in episodesOfTaggedSeries)
{
return 0;
result[entry.CleanValue] = (entry.Count, taggedLookup.GetValueOrDefault(entry.CleanValue));
}
var episodes = context.BaseItems.AsNoTracking()
.Where(e => e.Type == episodeTypeName && e.SeriesId != null)
.WhereOneOrMany(seriesIds, e => e.SeriesId!.Value);
return _queryHelpers.ApplyAccessFiltering(context, episodes, accessFilter).Count();
return result;
}
private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds)
=> context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id));
/// <inheritdoc/>
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
{
@@ -622,4 +731,12 @@ public class ItemCountService : IItemCountService
return result is null ? (0, 0) : (result.Played, result.Total);
}
/// <summary>
/// A by-name item, reduced to the three columns the counting keys off.
/// </summary>
/// <param name="Id">The id of the by-name item.</param>
/// <param name="Name">The name of the by-name item.</param>
/// <param name="CleanName">The cleaned name of the by-name item.</param>
private sealed record NameItem(Guid Id, string? Name, string? CleanName);
}
@@ -176,14 +176,6 @@ public class ItemPersistenceService : IItemPersistenceService
var context = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
if (!await context.BaseItems
.AnyAsync(bi => bi.Id == item.Id, cancellationToken)
.ConfigureAwait(false))
{
_logger.LogWarning("Unable to save ImageInfo for non existing BaseItem");
return;
}
await context.BaseItemImageInfos
.Where(e => e.ItemId == item.Id)
.ExecuteDeleteAsync(cancellationToken)
@@ -193,7 +185,26 @@ public class ItemPersistenceService : IItemPersistenceService
.AddRangeAsync(images, cancellationToken)
.ConfigureAwait(false);
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
try
{
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
catch (DbUpdateException)
{
// Checking that the item exists before writing leaves a gap a scan can delete it
// through, turning the insert into a foreign key violation that fails the whole
// refresh instead of the no-op intended here. Let the insert be the check: it is the
// only point at which the answer cannot go stale. Nothing is orphaned by the delete
// above, because deleting the item cascades to its images anyway.
if (await context.BaseItems
.AnyAsync(bi => bi.Id == item.Id, cancellationToken)
.ConfigureAwait(false))
{
throw;
}
_logger.LogWarning("Unable to save ImageInfo for non existing BaseItem {ItemId}", item.Id);
}
}
}
@@ -183,7 +183,13 @@ internal class JellyfinMigrationService
}
}
public async Task MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider? serviceProvider)
/// <summary>
/// Runs all pending migrations of the requested stage.
/// </summary>
/// <param name="stage">The stage to migrate.</param>
/// <param name="serviceProvider">The service provider handed to the migrations.</param>
/// <returns>A value indicating whether at least one migration has been applied.</returns>
public async Task<bool> MigrateStepAsync(JellyfinMigrationStageTypes stage, IServiceProvider serviceProvider)
{
var logger = _startupLogger.With(_loggerFactory.CreateLogger<JellyfinMigrationService>()).BeginGroup($"Migrate stage {stage}.");
ICollection<CodeMigration> migrationStage = (Migrations.FirstOrDefault(e => e.Stage == stage) as ICollection<CodeMigration>) ?? [];
@@ -297,6 +303,8 @@ internal class JellyfinMigrationService
completedMigrations++;
}
return completedMigrations > 0;
}
}
@@ -445,10 +453,10 @@ internal class JellyfinMigrationService
private class InternalCodeMigration : IInternalMigration
{
private readonly CodeMigration _codeMigration;
private readonly IServiceProvider? _serviceProvider;
private readonly IServiceProvider _serviceProvider;
private JellyfinDbContext _dbContext;
public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider? serviceProvider, JellyfinDbContext dbContext)
public InternalCodeMigration(CodeMigration codeMigration, IServiceProvider serviceProvider, JellyfinDbContext dbContext)
{
_codeMigration = codeMigration;
_serviceProvider = serviceProvider;
@@ -29,7 +29,7 @@ internal class MigrateLibraryUserData : IAsyncMigrationRoutine
private readonly IDbContextFactory<JellyfinDbContext> _provider;
public MigrateLibraryUserData(
IStartupLogger<MigrateLibraryDb> startupLogger,
IStartupLogger<MigrateLibraryUserData> startupLogger,
IDbContextFactory<JellyfinDbContext> provider,
IServerApplicationPaths paths)
{
@@ -24,7 +24,7 @@ internal class ReseedFolderFlag : IAsyncMigrationRoutine
private readonly IDbContextFactory<JellyfinDbContext> _provider;
public ReseedFolderFlag(
IStartupLogger<MigrateLibraryDb> startupLogger,
IStartupLogger<ReseedFolderFlag> startupLogger,
IDbContextFactory<JellyfinDbContext> provider,
IServerApplicationPaths paths)
{
@@ -4,8 +4,6 @@ using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Server.ServerSetupApp;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Stages;
@@ -22,66 +20,45 @@ internal class CodeMigration(Type migrationType, JellyfinMigrationAttribute meta
return Metadata.Order.ToString("yyyyMMddHHmmsss", CultureInfo.InvariantCulture) + "_" + Metadata.Name!;
}
private IServiceCollection MigrationServices(IServiceProvider serviceProvider, IStartupLogger logger)
public async Task Perform(IServiceProvider serviceProvider, IStartupLogger logger, CancellationToken cancellationToken)
{
var childServiceCollection = new ServiceCollection()
.AddSingleton(serviceProvider)
.AddSingleton(logger)
.AddSingleton(typeof(IStartupLogger<>), typeof(NestedStartupLogger<>))
.AddSingleton<StartupLogTopic>(logger.Topic!);
foreach (ServiceDescriptor service in serviceProvider.GetRequiredService<IServiceCollection>())
{
if (service.Lifetime == ServiceLifetime.Singleton && !service.ServiceType.IsGenericTypeDefinition)
{
childServiceCollection.AddSingleton(service.ServiceType, _ => serviceProvider.GetService(service.ServiceType)!);
continue;
}
childServiceCollection.Add(service);
}
return childServiceCollection;
}
public async Task Perform(IServiceProvider? serviceProvider, IStartupLogger logger, CancellationToken cancellationToken)
{
#pragma warning disable CS0618 // Type or member is obsolete
if (typeof(IMigrationRoutine).IsAssignableFrom(MigrationType))
{
if (serviceProvider is null)
{
((IMigrationRoutine)Activator.CreateInstance(MigrationType)!).Perform();
}
else
{
using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider();
((IMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).Perform();
#pragma warning restore CS0618 // Type or member is obsolete
}
}
else if (typeof(IAsyncMigrationRoutine).IsAssignableFrom(MigrationType))
{
if (serviceProvider is null)
{
await ((IAsyncMigrationRoutine)Activator.CreateInstance(MigrationType)!).PerformAsync(cancellationToken).ConfigureAwait(false);
}
else
{
using var migrationServices = MigrationServices(serviceProvider, logger).BuildServiceProvider();
await ((IAsyncMigrationRoutine)ActivatorUtilities.CreateInstance(migrationServices, MigrationType)).PerformAsync(cancellationToken).ConfigureAwait(false);
}
}
else
if (!IsMigrationRoutine(MigrationType))
{
throw new InvalidOperationException($"The type {MigrationType} does not implement either IMigrationRoutine or IAsyncMigrationRoutine and is not a valid migration type");
}
}
private class NestedStartupLogger<TCategory> : StartupLogger<TCategory>
{
public NestedStartupLogger(ILogger logger, StartupLogTopic topic) : base(logger, topic)
// The routine runs against a scope of the applications own container. Copying the application service
// descriptors into a child container instead would make that child container the owner of every singleton it
// forwards, so disposing it after the migration would also dispose the applications own instance of services
// like the ProviderManager and leave the server broken until the next restart.
var scope = serviceProvider.CreateAsyncScope();
await using (scope.ConfigureAwait(false))
{
// Nests everything the routine logs through an injected IStartupLogger under the migrations own topic.
using (StartupLogger.BeginAmbientTopic(logger.Topic))
{
await RunAsync(ActivatorUtilities.CreateInstance(scope.ServiceProvider, MigrationType), cancellationToken).ConfigureAwait(false);
}
}
}
// The obsolete IMigrationRoutine is still implemented by every routine that predates the async interface, so
// the members that have to touch it are grouped here behind a single suppression.
#pragma warning disable CS0618 // Type or member is obsolete
private static bool IsMigrationRoutine(Type migrationType)
{
return typeof(IMigrationRoutine).IsAssignableFrom(migrationType) || typeof(IAsyncMigrationRoutine).IsAssignableFrom(migrationType);
}
private static async Task RunAsync(object routine, CancellationToken cancellationToken)
{
if (routine is IMigrationRoutine migrationRoutine)
{
migrationRoutine.Perform();
return;
}
await ((IAsyncMigrationRoutine)routine).PerformAsync(cancellationToken).ConfigureAwait(false);
}
#pragma warning restore CS0618 // Type or member is obsolete
}
+35 -12
View File
@@ -61,6 +61,7 @@ namespace Jellyfin.Server
private static ILogger _logger = NullLogger.Instance;
private static bool _restartOnShutdown;
private static IStartupLogger<JellyfinMigrationService>? _migrationLogger;
private static bool _optimizeDatabaseAfterMigration;
private static string? _restoreFromBackup;
/// <summary>
@@ -180,9 +181,7 @@ namespace Jellyfin.Server
})
.ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig))
.UseSerilog()
.ConfigureServices(e => e
.RegisterStartupLogger()
.AddSingleton<IServiceCollection>(e))
.ConfigureServices(e => e.RegisterStartupLogger())
.Build();
/*
@@ -209,14 +208,15 @@ namespace Jellyfin.Server
await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
// "Preparing migrations" carries through the DB read; per-migration progress is reported
// as "Running migration X of Y" from inside the step once the pending set is known.
await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
_optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
SetupServer.ReportActivity(StartupActivity.InitializingServices);
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
_appHost = appHost;
await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
_optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.AppInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
await jellyfinMigrationService.CleanupSystemAfterMigration(_logger).ConfigureAwait(false);
await OptimizeDatabaseAfterMigrationAsync(appHost.ServiceProvider).ConfigureAwait(false);
try
{
configurationCompleted = true;
@@ -271,12 +271,11 @@ namespace Jellyfin.Server
// Don't throw additional exception if startup failed.
if (appHost.ServiceProvider is not null)
{
_logger.LogInformation("Running query planner optimizations in the database... This might take a while");
_logger.LogInformation("Optimizing the database... This might take a while");
// Deliberately untimed: a truncated optimization leaves the statistics incomplete.
var databaseProvider = appHost.ServiceProvider.GetRequiredService<IJellyfinDatabaseProvider>();
using var shutdownSource = new CancellationTokenSource();
shutdownSource.CancelAfter((int)TimeSpan.FromSeconds(60).TotalMicroseconds);
await databaseProvider.RunShutdownTask(shutdownSource.Token).ConfigureAwait(false);
await databaseProvider.RunShutdownTask(CancellationToken.None).ConfigureAwait(false);
}
_appHost = null;
@@ -307,14 +306,13 @@ namespace Jellyfin.Server
.AddSingleton<ServerApplicationPaths>(appPaths)
.RegisterStartupLogger();
migrationStartupServiceProvider.AddSingleton(migrationStartupServiceProvider);
var startupService = migrationStartupServiceProvider.BuildServiceProvider();
PrepareDatabaseProvider(startupService);
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(startupService);
await jellyfinMigrationService.CheckFirstTimeRunOrMigration(appPaths, startupOptions).ConfigureAwait(false);
await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false);
_optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(Migrations.Stages.JellyfinMigrationStageTypes.PreInitialisation, startupService).ConfigureAwait(false);
}
/// <summary>
@@ -329,7 +327,32 @@ namespace Jellyfin.Server
public static async Task ApplyCoreMigrationsAsync(IServiceProvider serviceProvider, Migrations.Stages.JellyfinMigrationStageTypes jellyfinMigrationStage)
{
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(serviceProvider, _migrationLogger!);
await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false);
_optimizeDatabaseAfterMigration |= await jellyfinMigrationService.MigrateStepAsync(jellyfinMigrationStage, serviceProvider).ConfigureAwait(false);
}
private static async Task OptimizeDatabaseAfterMigrationAsync(IServiceProvider serviceProvider)
{
if (!_optimizeDatabaseAfterMigration)
{
return;
}
// Reset first: a restart runs no migrations and must not optimize again.
_optimizeDatabaseAfterMigration = false;
SetupServer.ReportActivity(StartupActivity.OptimizingDatabase);
_logger.LogInformation("Migrations have been applied, optimizing the database... This might take a while");
try
{
// Deliberately untimed: incomplete statistics are worse than a slow start.
var databaseProvider = serviceProvider.GetRequiredService<IJellyfinDatabaseProvider>();
await databaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
}
catch (Exception ex)
{
// A missed optimization only costs performance, so never fail startup over this.
_logger.LogError(ex, "Error while optimizing the database after migration");
}
}
/// <summary>
@@ -27,6 +27,9 @@ public static class StartupActivity
/// <summary>Bringing up core services and plugins.</summary>
public const string InitializingServices = "Initializing services";
/// <summary>Refreshing the database statistics after migrations have run.</summary>
public const string OptimizingDatabase = "Optimizing database";
/// <summary>Running the final startup tasks.</summary>
public const string FinishingStartup = "Finishing startup";
@@ -1,5 +1,6 @@
using System;
using System.Globalization;
using System.Threading;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@@ -8,6 +9,8 @@ namespace Jellyfin.Server.ServerSetupApp;
/// <inheritdoc/>
public class StartupLogger : IStartupLogger
{
private static readonly AsyncLocal<StartupLogTopic?> _ambientTopic = new();
private readonly StartupLogTopic? _topic;
/// <summary>
@@ -17,6 +20,7 @@ public class StartupLogger : IStartupLogger
public StartupLogger(ILogger logger)
{
BaseLogger = logger;
_topic = _ambientTopic.Value;
}
/// <summary>
@@ -39,6 +43,18 @@ public class StartupLogger : IStartupLogger
/// </summary>
protected ILogger BaseLogger { get; set; }
/// <summary>
/// Makes <paramref name="topic"/> the topic that loggers created on this execution context attach to.
/// </summary>
/// <param name="topic">The topic to nest newly created loggers under.</param>
/// <returns>A scope that restores the previously ambient topic when disposed.</returns>
internal static IDisposable BeginAmbientTopic(StartupLogTopic? topic)
{
var scope = new AmbientTopicScope(_ambientTopic.Value);
_ambientTopic.Value = topic;
return scope;
}
/// <inheritdoc/>
public IStartupLogger BeginGroup(FormattableString logEntry)
{
@@ -121,4 +137,19 @@ public class StartupLogger : IStartupLogger
Topic.Children.Add(startupEntry);
}
}
private sealed class AmbientTopicScope : IDisposable
{
private readonly StartupLogTopic? _previous;
public AmbientTopicScope(StartupLogTopic? previous)
{
_previous = previous;
}
public void Dispose()
{
_ambientTopic.Value = _previous;
}
}
}
@@ -432,12 +432,18 @@ namespace MediaBrowser.Controller.Entities
public string? HasNoSubtitleTrackWithLanguage { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to return only items nothing names any more.
/// </summary>
public bool? IsDeadArtist { get; set; }
public bool? IsDeadStudio { get; set; }
public bool? IsDeadGenre { get; set; }
/// <summary>
/// Gets or sets a value indicating whether to return only items nothing names any more.
/// </summary>
public bool? IsDeadPerson { get; set; }
/// <summary>
@@ -106,6 +106,13 @@ namespace MediaBrowser.Controller.Library
/// <returns>Task{Person}.</returns>
Person? GetPerson(string name);
/// <summary>
/// Gets a Person, creating and persisting it if no item exists for the name yet.
/// </summary>
/// <param name="name">The name of the person.</param>
/// <returns>The person.</returns>
Person GetOrCreatePerson(string name);
/// <summary>
/// Finds the by path.
/// </summary>
@@ -152,15 +159,6 @@ namespace MediaBrowser.Controller.Library
/// <exception cref="ArgumentOutOfRangeException">Throws if year is invalid.</exception>
Year GetYear(int value);
/// <summary>
/// Validate and refresh the People sub-set of the IBN.
/// The items are stored in the db but not loaded into memory until actually requested by an operation.
/// </summary>
/// <param name="progress">The progress.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
Task ValidatePeopleAsync(IProgress<double> progress, CancellationToken cancellationToken);
/// <summary>
/// Reloads the root media folder.
/// </summary>
@@ -709,6 +707,14 @@ namespace MediaBrowser.Controller.Library
/// <returns><c>true</c> if ignored, <c>false</c> otherwise.</returns>
bool IgnoreFile(FileSystemMetadata file, BaseItem parent);
/// <summary>
/// Gets the id a <see cref="Person"/> item for the name would have, without looking it up
/// or creating it.
/// </summary>
/// <param name="name">The name of the person.</param>
/// <returns>The item id for the name.</returns>
Guid GetPersonId(string name);
Guid GetStudioId(string name);
Guid GetGenreId(string name);
@@ -753,6 +759,18 @@ namespace MediaBrowser.Controller.Library
/// <returns>The item counts grouped by type.</returns>
ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, User? user);
/// <summary>
/// Gets item counts for several "by-name" items of the same kind. Kinds keyed by a cleaned
/// item value - artists, genres and studios - are answered in one set of queries for the
/// whole batch; the rest fall back to one query per item.
/// </summary>
/// <param name="kind">The kind of the name items.</param>
/// <param name="ids">The IDs of the name items.</param>
/// <param name="relatedItemKinds">The item kinds to count.</param>
/// <param name="user">The user for access filtering.</param>
/// <returns>The item counts of each requested id.</returns>
Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, User? user);
/// <summary>
/// Batch-fetches child counts for multiple parent folders.
/// Returns the count of immediate children (non-recursive) for each parent.
@@ -17,7 +17,8 @@ namespace MediaBrowser.Controller.LibraryTaskScheduler;
/// </summary>
public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibraryScheduler, IAsyncDisposable
{
private const int CleanupGracePeriod = 60;
private static readonly TimeSpan _cleanupGracePeriod = TimeSpan.FromSeconds(60);
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly ILogger<LimitedConcurrencyLibraryScheduler> _logger;
private readonly IServerConfigurationManager _serverConfigurationManager;
@@ -31,6 +32,8 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
private readonly Lock _taskLock = new();
private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>();
private readonly CancellationTokenSource _disposeTokenSource = new();
private readonly TimeSpan _gracePeriod;
private volatile int _workCounter;
private Task? _cleanupTask;
@@ -46,10 +49,34 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
IHostApplicationLifetime hostApplicationLifetime,
ILogger<LimitedConcurrencyLibraryScheduler> logger,
IServerConfigurationManager serverConfigurationManager)
: this(hostApplicationLifetime, logger, serverConfigurationManager, _cleanupGracePeriod)
{
}
internal LimitedConcurrencyLibraryScheduler(
IHostApplicationLifetime hostApplicationLifetime,
ILogger<LimitedConcurrencyLibraryScheduler> logger,
IServerConfigurationManager serverConfigurationManager,
TimeSpan gracePeriod)
{
_hostApplicationLifetime = hostApplicationLifetime;
_logger = logger;
_serverConfigurationManager = serverConfigurationManager;
_gracePeriod = gracePeriod;
}
/// <summary>
/// Gets the number of runners the scheduler currently keeps alive.
/// </summary>
internal int ActiveRunnerCount
{
get
{
lock (_taskLock)
{
return _taskRunners.Count;
}
}
}
private void ScheduleTaskCleanup()
@@ -68,31 +95,65 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
async Task RunCleanupTask()
{
_logger.LogDebug("Schedule cleanup task in {CleanupGracePerioid} sec.", CleanupGracePeriod);
await Task.Delay(TimeSpan.FromSeconds(CleanupGracePeriod)).ConfigureAwait(false);
if (_disposed)
while (true)
{
_logger.LogDebug("Abort cleaning up, already disposed.");
return;
}
lock (_taskLock)
{
if (_tasks.Reader.Count > 0 || _workCounter > 0)
_logger.LogDebug("Schedule cleanup task in {CleanupGracePeriod}.", _gracePeriod);
try
{
_logger.LogDebug("Delay cleanup task, operations still running.");
// tasks are still there so its still in use. Reschedule cleanup task.
// we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended.
_cleanupTask = RunCleanupTask();
await Task.Delay(_gracePeriod, _disposeTokenSource.Token).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
_logger.LogDebug("Abort cleaning up, already disposed.");
return;
}
}
_logger.LogDebug("Cleanup runners.");
foreach (var item in _taskRunners.ToArray())
if (_disposed)
{
_logger.LogDebug("Abort cleaning up, already disposed.");
return;
}
CancellationTokenSource[] runners;
lock (_taskLock)
{
if (_tasks.Reader.Count > 0 || _workCounter > 0)
{
_logger.LogDebug("Delay cleanup task, operations still running.");
// tasks are still there so its still in use. Wait another grace period.
// we cannot just exit here and rely on the other invoker because there is a considerable timeframe where it could have already ended.
continue;
}
runners = [.. _taskRunners.Keys];
// Retire the runners before they are told to stop: an operation starting while
// they wind down must spawn its own instead of counting these towards the fanout.
_taskRunners.Clear();
// Hand the next operation the ability to schedule a cleanup again. Without this
// the very first cleanup would be the only one that ever runs.
_cleanupTask = null;
}
_logger.LogDebug("Cleanup runners.");
await StopRunners(runners).ConfigureAwait(false);
return;
}
}
}
private static async Task StopRunners(CancellationTokenSource[] runners)
{
foreach (var runner in runners)
{
try
{
await item.Key.CancelAsync().ConfigureAwait(false);
_taskRunners.Remove(item.Key);
await runner.CancelAsync().ConfigureAwait(false);
}
catch (ObjectDisposedException)
{
// The runner already stopped on its own and disposed its stop source.
}
}
}
@@ -127,12 +188,17 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
{
var stopToken = new CancellationTokenSource();
var combinedSource = CancellationTokenSource.CreateLinkedTokenSource(stopToken.Token, _hostApplicationLifetime.ApplicationStopping);
// Keyed on its own stop source, because cancelling that is what reaches the linked
// source the runner waits on. Cancellation does not travel the other way.
// Started without the runner's own token: a task cancelled before it is scheduled
// never runs its body, so it would never take itself out of _taskRunners again.
_taskRunners.Add(
combinedSource,
stopToken,
Task.Factory.StartNew(
ItemWorker,
(combinedSource, stopToken),
combinedSource.Token,
(stopToken, combinedSource),
CancellationToken.None,
TaskCreationOptions.PreferFairness,
TaskScheduler.Default));
}
@@ -145,7 +211,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
_deadlockDetector.Value = stopToken.TaskStop;
try
{
while (!stopToken.GlobalStop.Token.IsCancellationRequested)
while (!stopToken.GlobalStop.IsCancellationRequested)
{
var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false);
try
@@ -162,15 +228,24 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
}
}
}
catch (OperationCanceledException) when (stopToken.TaskStop.IsCancellationRequested)
catch (OperationCanceledException) when (stopToken.GlobalStop.IsCancellationRequested)
{
// thats how you do it, interupt the waiter thread. There is nothing to do here when it was on purpose.
}
catch (ChannelClosedException)
{
// the scheduler was disposed and will not hand out any more work.
}
finally
{
_logger.LogDebug("Cleanup Runner'.");
_deadlockDetector.Value = default!;
_taskRunners.Remove(stopToken.TaskStop);
lock (_taskLock)
{
_taskRunners.Remove(stopToken.TaskStop);
}
stopToken.GlobalStop.Dispose();
stopToken.TaskStop.Dispose();
}
@@ -195,7 +270,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
finally
{
item.Progress.Report(100);
item.Done.SetResult();
item.Done.TrySetResult();
}
}
@@ -285,16 +360,33 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
_disposed = true;
_tasks.Writer.Complete();
foreach (var item in _taskRunners)
// Nobody is left to run these, so release whoever is waiting on them.
while (_tasks.Reader.TryRead(out var item))
{
await item.Key.CancelAsync().ConfigureAwait(false);
item.Done.TrySetResult();
}
if (_cleanupTask is not null)
CancellationTokenSource[] runners;
Task? cleanupTask;
lock (_taskLock)
{
await _cleanupTask.ConfigureAwait(false);
_cleanupTask?.Dispose();
runners = [.. _taskRunners.Keys];
_taskRunners.Clear();
cleanupTask = _cleanupTask;
}
await StopRunners(runners).ConfigureAwait(false);
// Cuts the grace period short instead of holding up shutdown for the rest of it.
await _disposeTokenSource.CancelAsync().ConfigureAwait(false);
if (cleanupTask is not null)
{
await cleanupTask.ConfigureAwait(false);
}
_disposeTokenSource.Dispose();
}
private class TaskQueueItem
@@ -18,7 +18,6 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BitFaster.Caching" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" />
</ItemGroup>
@@ -442,7 +442,8 @@ namespace MediaBrowser.Controller.MediaEncoding
&& (state.VideoStream.VideoRangeType == VideoRangeType.HDR10
|| IsHdr10Plus(state.VideoStream)
|| IsDoviWithHdr10Bl(state.VideoStream)
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG);
|| state.VideoStream.VideoRangeType == VideoRangeType.HLG
|| state.VideoStream.VideoRangeType == VideoRangeType.DOVIInvalid);
}
private static bool IsDeinterlaceAvailable(EncodingJobInfo state)
@@ -695,7 +696,11 @@ namespace MediaBrowser.Controller.MediaEncoding
"ogg" or "oga" or "ogv" or "webm" or "webma" => "opus",
"m4a" or "m4b" or "mp4" or "mov" or "mkv" or "mka" => "aac",
"ts" or "avi" or "flv" or "f4v" or "swf" => "mp3",
_ => inferredCodec
// Containers that share their name with the codec they carry.
"aac" or "ac3" or "alac" or "dts" or "eac3" or "flac" or "mp2" or "mp3" or "opus" or "truehd" or "vorbis" => inferredCodec,
// Anything else - manifests such as m3u8/mpd in particular - names a container that
// is not an audio codec. Never hand that name to ffmpeg as an encoder.
_ => "aac"
};
}
@@ -1386,7 +1391,8 @@ namespace MediaBrowser.Controller.MediaEncoding
or VideoRangeType.DOVIWithEL
or VideoRangeType.DOVIWithHDR10Plus
or VideoRangeType.DOVIWithELHDR10Plus
or VideoRangeType.DOVIInvalid;
|| (rangeType == VideoRangeType.DOVIInvalid
&& string.Equals(stream.ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase)); // invalid may be hlg now
}
public static bool IsDovi(MediaStream stream)
@@ -1396,7 +1402,8 @@ namespace MediaBrowser.Controller.MediaEncoding
return IsDoviWithHdr10Bl(stream)
|| (rangeType is VideoRangeType.DOVI
or VideoRangeType.DOVIWithHLG
or VideoRangeType.DOVIWithSDR);
or VideoRangeType.DOVIWithSDR
or VideoRangeType.DOVIInvalid);
}
public static bool IsHdr10Plus(MediaStream stream)
@@ -1416,7 +1423,8 @@ namespace MediaBrowser.Controller.MediaEncoding
private static DynamicHdrMetadataRemovalPlan ShouldRemoveDynamicHdrMetadata(EncodingJobInfo state)
{
var videoStream = state.VideoStream;
if (videoStream.VideoRange is not VideoRange.HDR)
if (videoStream.VideoRange is not VideoRange.HDR
&& videoStream.VideoRangeType != VideoRangeType.DOVIInvalid)
{
return DynamicHdrMetadataRemovalPlan.None;
}
@@ -6311,7 +6319,7 @@ namespace MediaBrowser.Controller.MediaEncoding
string.Join(',', overlayFilters));
var mapPrefix = Convert.ToInt32(state.SubtitleStream.IsExternal);
var subtitleStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.SubtitleStream);
var subtitleStreamIndex = GetSubtitleStreamIndexForFfmpeg(state.MediaSource, state.SubtitleStream);
var videoStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.VideoStream);
if (hasSubs)
@@ -7943,6 +7951,24 @@ namespace MediaBrowser.Controller.MediaEncoding
return -1;
}
public static int GetSubtitleStreamIndexForFfmpeg(MediaSourceInfo mediaSource, MediaStream subtitleStream)
{
var index = FindIndex(mediaSource.MediaStreams, subtitleStream);
if (index == -1 || subtitleStream.IsExternal || mediaSource.VideoType != VideoType.BluRay)
{
return index;
}
var hiddenStreamsBefore = mediaSource.MediaStreams.Count(s =>
s.Type == MediaStreamType.Audio
&& !s.IsExternal
&& (string.Equals(s.Codec, "truehd", StringComparison.OrdinalIgnoreCase)
|| string.Equals(s.Codec, "atmos", StringComparison.OrdinalIgnoreCase))
&& s.Index < subtitleStream.Index);
return index + hiddenStreamsBefore;
}
public static bool IsCopyCodec(string codec)
{
return string.Equals(codec, "copy", StringComparison.OrdinalIgnoreCase);
@@ -36,6 +36,18 @@ public interface IItemCountService
/// <returns>The item counts grouped by type.</returns>
ItemCounts GetItemCountsForNameItem(BaseItemKind kind, Guid id, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter);
/// <summary>
/// Gets item counts for several "by-name" items of the same kind. Kinds keyed by a cleaned
/// item value - artists, genres and studios - are answered in one set of queries for the whole
/// batch; the rest fall back to one query per id.
/// </summary>
/// <param name="kind">The kind of the name items.</param>
/// <param name="ids">The IDs of the name items.</param>
/// <param name="relatedItemKinds">The item kinds to count.</param>
/// <param name="accessFilter">A pre-configured query with user access filtering settings.</param>
/// <returns>The item counts of each requested id.</returns>
Dictionary<Guid, ItemCounts> GetItemCountsForNameItems(BaseItemKind kind, IReadOnlyList<Guid> ids, BaseItemKind[] relatedItemKinds, InternalItemsQuery accessFilter);
/// <summary>
/// Gets the count of played items that are descendants of the specified ancestor.
/// </summary>
@@ -5,13 +5,19 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using MediaBrowser.Model.IO;
namespace MediaBrowser.Controller.Providers
{
public class DirectoryService : IDirectoryService
{
// TODO make static and switch to FastConcurrentLru.
// TODO replace with one shared bounded cache.
private const int MaxCachedRecords = 100_000;
private const int AccessIntervalMs = 1_000;
// Timeout cache if no access for 5 minutes.
private const int IdleTimeoutMs = 5 * 60 * 1_000;
private readonly ConcurrentDictionary<string, FileSystemMetadata[]> _cache = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, FileSystemMetadata> _fileCache = new(StringComparer.Ordinal);
@@ -20,6 +26,12 @@ namespace MediaBrowser.Controller.Providers
private readonly IFileSystem _fileSystem;
// ConcurrentDictionary.Count locks the dictionary, so keep an estimated counter.
// Concurrent factory runs can overcount and a clear racing an add can undercount,
// it only has to be roughly right.
private int _recordCount;
private long _lastAccess = Environment.TickCount64;
public DirectoryService(IFileSystem fileSystem)
{
_fileSystem = fileSystem;
@@ -27,20 +39,26 @@ namespace MediaBrowser.Controller.Providers
public FileSystemMetadata[] GetFileSystemEntries(string path)
{
DropCacheIfIdleOrFull();
return _cache.GetOrAdd(
path,
static (p, fileSystem) =>
static (p, state) =>
{
FileSystemMetadata[] entries;
try
{
return fileSystem.GetFileSystemEntries(p).ToArray();
entries = state.FileSystem.GetFileSystemEntries(p).ToArray();
}
catch (DirectoryNotFoundException)
{
return [];
entries = [];
}
Interlocked.Add(ref state.Service._recordCount, entries.Length + 1);
return entries;
},
_fileSystem);
(FileSystem: _fileSystem, Service: this));
}
public List<FileSystemMetadata> GetDirectories(string path)
@@ -89,13 +107,18 @@ namespace MediaBrowser.Controller.Providers
public FileSystemMetadata? GetFileSystemEntry(string path)
{
DropCacheIfIdleOrFull();
if (!_fileCache.TryGetValue(path, out var result))
{
var file = _fileSystem.GetFileSystemInfo(path);
if (file?.Exists ?? false)
{
result = file;
_fileCache.TryAdd(path, result);
if (_fileCache.TryAdd(path, result))
{
Interlocked.Increment(ref _recordCount);
}
}
}
@@ -107,32 +130,96 @@ namespace MediaBrowser.Controller.Providers
public IReadOnlyList<string> GetFilePaths(string path, bool clearCache)
{
if (clearCache)
if (clearCache && _filePathCache.TryRemove(path, out var cached))
{
_filePathCache.TryRemove(path, out _);
Interlocked.Add(ref _recordCount, -(cached.Count + 1));
}
DropCacheIfIdleOrFull();
var filePaths = _filePathCache.GetOrAdd(
path,
static (p, fileSystem) =>
static (p, state) =>
{
List<string> filePaths;
try
{
return fileSystem.GetFilePaths(p).OrderBy(x => x).ToList();
filePaths = state.FileSystem.GetFilePaths(p).OrderBy(x => x).ToList();
}
catch (DirectoryNotFoundException)
{
return [];
filePaths = [];
}
Interlocked.Add(ref state.Service._recordCount, filePaths.Count + 1);
return filePaths;
},
_fileSystem);
(FileSystem: _fileSystem, Service: this));
return filePaths;
}
public void Invalidate(string path)
{
Forget(path);
var parent = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(parent))
{
Forget(parent);
}
}
public void Move(string source, string destination)
{
Directory.Move(source, destination);
Invalidate(source);
Invalidate(destination);
}
public bool IsAccessible(string path)
{
return _fileSystem.GetFileSystemEntryPaths(path).Any();
}
private void DropCacheIfIdleOrFull()
{
var nowMs = Environment.TickCount64;
var idleMs = nowMs - _lastAccess;
if (idleMs >= IdleTimeoutMs || _recordCount >= MaxCachedRecords)
{
_cache.Clear();
_fileCache.Clear();
_filePathCache.Clear();
_recordCount = 0;
_lastAccess = nowMs;
return;
}
if (idleMs >= AccessIntervalMs)
{
_lastAccess = nowMs;
}
}
private void Forget(string path)
{
if (_cache.TryRemove(path, out var entries))
{
Interlocked.Add(ref _recordCount, -(entries.Length + 1));
}
if (_fileCache.TryRemove(path, out _))
{
Interlocked.Decrement(ref _recordCount);
}
if (_filePathCache.TryRemove(path, out var filePaths))
{
Interlocked.Add(ref _recordCount, -(filePaths.Count + 1));
}
}
}
}
@@ -23,6 +23,19 @@ namespace MediaBrowser.Controller.Providers
IReadOnlyList<string> GetFilePaths(string path, bool clearCache);
/// <summary>
/// Forgets what is cached about a path and about the directory containing it.
/// </summary>
/// <param name="path">The file or directory path that changed.</param>
void Invalidate(string path);
/// <summary>
/// Moves a directory and forgets what is cached about both paths.
/// </summary>
/// <param name="source">The directory to move.</param>
/// <param name="destination">The path to move the directory to.</param>
void Move(string source, string destination);
bool IsAccessible(string path);
}
}
@@ -501,7 +501,7 @@ namespace MediaBrowser.Controller.SyncPlay.GroupStates
{
// Client, that was buffering, resumed playback but did not update others in time.
delayTicks = context.GetHighestPing() * 2 * TimeSpan.TicksPerMillisecond;
delayTicks = Math.Max(delayTicks, context.DefaultPing);
delayTicks = Math.Max(delayTicks, TimeSpan.FromMilliseconds(context.DefaultPing).Ticks);
context.LastActivity = currentTime.AddTicks(delayTicks);
@@ -157,7 +157,10 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// </summary>
public void RestoreSortedPlaylist()
{
if (PlayingItemIndex != NoPlayingItemIndex)
// The shuffled playlist is only populated while the shuffle mode is active, so there is
// nothing to map back when the playlist is already sorted. Guarding on its contents keeps
// a redundant request for the sorted mode from indexing an empty list.
if (PlayingItemIndex != NoPlayingItemIndex && _shuffledPlaylist.Count > 0)
{
var playingItem = _shuffledPlaylist[PlayingItemIndex];
PlayingItemIndex = _sortedPlaylist.IndexOf(playingItem);
@@ -87,7 +87,7 @@ public class BdInfoExaminer : IBlurayExaminer
if (playlist.StreamClips is not null && playlist.StreamClips.Count > 0)
{
// Get the files in the playlist
outputStream.Files = playlist.StreamClips.Select(i => i.StreamFile.FileInfo.FullName).ToArray();
outputStream.Files = playlist.StreamClips.Where(i => i.AngleIndex == 0).Select(i => i.StreamFile.FileInfo.FullName).ToArray();
}
return outputStream;
@@ -1152,6 +1152,11 @@ namespace MediaBrowser.MediaEncoding.Encoder
{
process.Process.PriorityClass = ProcessPriorityClass.BelowNormal;
}
catch (InvalidOperationException)
{
// The process finished before its priority could be lowered. That says nothing
// about whether the platform allows it, so keep the capability for the next one.
}
catch (Exception ex)
{
_canSetProcessPriority = false;
@@ -1361,12 +1366,20 @@ namespace MediaBrowser.MediaEncoding.Encoder
return _configurationManager.GetEncodingOptions().EnableSubtitleExtraction;
}
private sealed class ProcessWrapper : IDisposable
internal sealed class ProcessWrapper : IDisposable
{
private readonly MediaEncoder _mediaEncoder;
// The exit event is raised on the thread pool, so it writes the state below while the
// caller that started the process is reading it.
private readonly Lock _exitLock = new();
private bool _disposed = false;
private bool _hasExited;
private int? _exitCode;
public ProcessWrapper(Process process, MediaEncoder mediaEncoder)
{
Process = process;
@@ -1376,49 +1389,84 @@ namespace MediaBrowser.MediaEncoding.Encoder
public Process Process { get; }
public bool HasExited { get; private set; }
// The exit event can lag behind the wait that returned, so ask the process rather than
// report one that has exited as still running.
public bool HasExited => ReadExitState().HasExited;
public int? ExitCode { get; private set; }
// As above: rather than report no exit code for a process that has one.
public int? ExitCode => ReadExitState().ExitCode;
private (bool HasExited, int? ExitCode) ReadExitState()
{
lock (_exitLock)
{
if (!_hasExited && !_disposed)
{
try
{
if (Process.HasExited)
{
_hasExited = true;
_exitCode = Process.ExitCode;
}
}
catch (InvalidOperationException)
{
// No process is associated with this object, or it was disposed from
// under us - ObjectDisposedException derives from this one.
}
}
return (_hasExited, _exitCode);
}
}
private void OnProcessExited(object sender, EventArgs e)
{
var process = (Process)sender;
HasExited = true;
lock (_exitLock)
{
_hasExited = true;
try
{
ExitCode = process.ExitCode;
}
catch
{
try
{
_exitCode = process.ExitCode;
}
catch
{
}
}
DisposeProcess(process);
// Only stop tracking it. The caller that started the process still holds it to read
// its output and its exit code, so disposing it here handed whoever was quickest to
// exit - an ffprobe on a file it rejects outright - an ObjectDisposedException.
Untrack();
}
private void DisposeProcess(Process process)
private void Untrack()
{
lock (_mediaEncoder._runningProcessesLock)
{
_mediaEncoder._runningProcesses.Remove(this);
}
process.Dispose();
}
public void Dispose()
{
if (!_disposed)
lock (_exitLock)
{
if (Process is not null)
if (_disposed)
{
Process.Exited -= OnProcessExited;
DisposeProcess(Process);
return;
}
_disposed = true;
}
_disposed = true;
Process.Exited -= OnProcessExited;
Untrack();
Process.Dispose();
}
}
}
@@ -649,7 +649,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
List<MediaStream> subtitleStreams,
CancellationToken cancellationToken)
{
var inputPath = _mediaEncoder.GetInputArgument(mediaSource.Path, mediaSource);
var inputPath = _mediaEncoder.GetInputPathArgument(mediaSource.Path, mediaSource);
var outputPaths = new List<string>();
var args = string.Format(
CultureInfo.InvariantCulture,
@@ -673,7 +673,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputCodec = IsCodecCopyable(subtitleStream.Codec) ? "copy" : "srt";
// FFmpeg does not provide an .idx/.sub muxer, so VobSub streams must be written as MKS files.
var outputFormatOption = MediaStream.IsVobSubFormat(subtitleStream.Codec) ? " -f matroska" : string.Empty;
var streamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
var streamIndex = EncodingHelper.GetSubtitleStreamIndexForFfmpeg(mediaSource, subtitleStream);
if (streamIndex == -1)
{
+10
View File
@@ -26,6 +26,8 @@ namespace MediaBrowser.Model.Dlna
internal const TranscodeReason VideoReasons = TranscodeReason.VideoCodecNotSupported | VideoCodecReasons;
internal const TranscodeReason DirectStreamReasons = AudioReasons | TranscodeReason.ContainerNotSupported | TranscodeReason.VideoCodecTagNotSupported;
private const string ManifestContainers = "hls,applehttp,dash";
private readonly ILogger _logger;
private readonly ITranscoderSupport _transcoderSupport;
private static readonly string[] _supportedHlsVideoCodecs = ["h264", "hevc", "vp9", "av1"];
@@ -718,6 +720,14 @@ namespace MediaBrowser.Model.Dlna
isEligibleForDirectPlay = false;
}
// A manifest is not a byte stream, so it cannot be handed to the client as one. The variant
// and segment URIs inside it are relative to the origin and do not resolve against the
// Jellyfin url the client would fetch it from.
if (ContainerHelper.ContainsContainer(ManifestContainers, item.Container))
{
isEligibleForDirectPlay = false;
}
if (bitrateLimitExceeded)
{
transcodeReasons = TranscodeReason.ContainerBitrateExceedsLimit;
+24 -6
View File
@@ -810,6 +810,11 @@ namespace MediaBrowser.Model.Entities
return (VideoRange.Unknown, VideoRangeType.Unknown);
}
var isPq = string.Equals(ColorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase);
var isHlg = string.Equals(ColorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase);
// Invalid DV only retains HDR when the base layer explicitly signals PQ or HLG.
var baseVideoRange = isPq || isHlg ? VideoRange.HDR : VideoRange.SDR;
var codecTag = CodecTag;
var dvProfile = DvProfile;
var rpuPresentFlag = RpuPresentFlag == 1;
@@ -834,7 +839,7 @@ namespace MediaBrowser.Model.Entities
4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG),
2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR),
// Out of Dolby Spec files should be marked as invalid
_ => (VideoRange.HDR, VideoRangeType.DOVIInvalid)
_ => (baseVideoRange, VideoRangeType.DOVIInvalid)
},
7 => (VideoRange.HDR, VideoRangeType.DOVIWithEL),
10 => dvBlCompatId switch
@@ -844,11 +849,26 @@ namespace MediaBrowser.Model.Entities
2 => (VideoRange.SDR, VideoRangeType.DOVIWithSDR),
4 => (VideoRange.HDR, VideoRangeType.DOVIWithHLG),
// Out of Dolby Spec files should be marked as invalid
_ => (VideoRange.HDR, VideoRangeType.DOVIInvalid)
_ => (baseVideoRange, VideoRangeType.DOVIInvalid)
},
_ => (VideoRange.SDR, VideoRangeType.SDR)
};
var expectedTransfer = dvRangeSet.Item2 switch
{
VideoRangeType.DOVIWithHDR10 or VideoRangeType.DOVIWithEL => "smpte2084",
VideoRangeType.DOVIWithHLG => "arib-std-b67",
_ => null
};
if (expectedTransfer is not null
&& (!string.Equals(ColorSpace, "bt2020nc", StringComparison.OrdinalIgnoreCase)
|| !string.Equals(ColorTransfer, expectedTransfer, StringComparison.OrdinalIgnoreCase)
|| !string.Equals(ColorPrimaries, "bt2020", StringComparison.OrdinalIgnoreCase)))
{
return (baseVideoRange, VideoRangeType.DOVIInvalid);
}
if (Hdr10PlusPresentFlag == true)
{
return dvRangeSet.Item2 switch
@@ -862,13 +882,11 @@ namespace MediaBrowser.Model.Entities
return dvRangeSet;
}
var colorTransfer = ColorTransfer;
if (string.Equals(colorTransfer, "smpte2084", StringComparison.OrdinalIgnoreCase))
if (isPq)
{
return Hdr10PlusPresentFlag == true ? (VideoRange.HDR, VideoRangeType.HDR10Plus) : (VideoRange.HDR, VideoRangeType.HDR10);
}
else if (string.Equals(colorTransfer, "arib-std-b67", StringComparison.OrdinalIgnoreCase))
else if (isHlg)
{
return (VideoRange.HDR, VideoRangeType.HLG);
}
@@ -32,6 +32,7 @@ public class LyricManager : ILyricManager
private readonly IFileSystem _fileSystem;
private readonly ILibraryMonitor _libraryMonitor;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly IDirectoryService _directoryService;
private readonly ILyricProvider[] _lyricProviders;
private readonly ILyricParser[] _lyricParsers;
@@ -43,6 +44,7 @@ public class LyricManager : ILyricManager
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="libraryMonitor">Instance of the <see cref="ILibraryMonitor"/> interface.</param>
/// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param>
/// <param name="directoryService">Instance of the <see cref="IDirectoryService"/> interface.</param>
/// <param name="lyricProviders">The list of <see cref="ILyricProvider"/>.</param>
/// <param name="lyricParsers">The list of <see cref="ILyricParser"/>.</param>
public LyricManager(
@@ -50,6 +52,7 @@ public class LyricManager : ILyricManager
IFileSystem fileSystem,
ILibraryMonitor libraryMonitor,
IMediaSourceManager mediaSourceManager,
IDirectoryService directoryService,
IEnumerable<ILyricProvider> lyricProviders,
IEnumerable<ILyricParser> lyricParsers)
{
@@ -57,6 +60,7 @@ public class LyricManager : ILyricManager
_fileSystem = fileSystem;
_libraryMonitor = libraryMonitor;
_mediaSourceManager = mediaSourceManager;
_directoryService = directoryService;
_lyricProviders = lyricProviders
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
.ToArray();
@@ -250,6 +254,8 @@ public class LyricManager : ILyricManager
{
_libraryMonitor.ReportFileSystemChangeComplete(path, false);
}
_directoryService.Invalidate(path);
}
return audio.RefreshMetadata(CancellationToken.None);
@@ -446,6 +452,8 @@ public class LyricManager : ILyricManager
await stream.CopyToAsync(fs).ConfigureAwait(false);
}
_directoryService.Invalidate(savePath);
return;
}
catch (Exception ex)
@@ -1143,16 +1143,21 @@ namespace MediaBrowser.Providers.Manager
return;
}
_refreshQueue.Enqueue((itemId, options), priority);
// PriorityQueue is not thread safe and the processor dequeues concurrently, so every
// touch of the queue takes the lock.
lock (_refreshQueueLock)
{
if (!_isProcessingRefreshQueue)
_refreshQueue.Enqueue((itemId, options), priority);
if (_isProcessingRefreshQueue)
{
_isProcessingRefreshQueue = true;
Task.Run(StartProcessingRefreshQueue);
return;
}
_isProcessingRefreshQueue = true;
}
Task.Run(StartProcessingRefreshQueue);
}
private async Task StartProcessingRefreshQueue()
@@ -1161,17 +1166,33 @@ namespace MediaBrowser.Providers.Manager
if (_disposed)
{
lock (_refreshQueueLock)
{
_isProcessingRefreshQueue = false;
}
return;
}
var cancellationToken = _disposeCancellationTokenSource.Token;
libraryManager.ClearIgnoreRuleCache();
while (_refreshQueue.TryDequeue(out var refreshItem, out _))
while (true)
{
if (_disposed)
(Guid ItemId, MetadataRefreshOptions RefreshOptions) refreshItem;
// Dequeueing and standing down happen under one lock, otherwise a refresh queued
// just after the queue ran dry would see a processor that has already stopped.
lock (_refreshQueueLock)
{
return;
if (_disposed
|| cancellationToken.IsCancellationRequested
|| !_refreshQueue.TryDequeue(out refreshItem, out _))
{
_isProcessingRefreshQueue = false;
break;
}
}
try
@@ -1188,19 +1209,21 @@ namespace MediaBrowser.Providers.Manager
await task.ConfigureAwait(false);
}
catch (OperationCanceledException)
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
break;
// Shutting down: the next pass sees the token and stands the processor down.
continue;
}
catch (Exception ex)
{
// Includes a provider that cancelled for its own reasons, such as an HTTP
// timeout, which must not stop the queue draining.
_logger.LogError(ex, "Error refreshing item");
}
}
lock (_refreshQueueLock)
if (!_disposed)
{
_isProcessingRefreshQueue = false;
libraryManager.ClearIgnoreRuleCache();
}
}
@@ -231,10 +231,28 @@ namespace MediaBrowser.Providers.MediaInfo
return Array.Empty<ExternalPathParserResult>();
}
// VobSub .sub payloads only carry per-track language metadata when read via
// their paired .idx file, so probe the .idx instead and skip the .sub. Pairing
// requires the same directory (ffprobe can't resolve a split pair) and an
// ordinal comparison (ffprobe matches the .sub by exact case on case-sensitive
// filesystems, so a looser match could suppress a .sub with no working .idx).
// An .idx file with no paired .sub cannot be probed at all, so it is left out
// entirely rather than surfaced (which would otherwise fail every probe and,
// since the .idx would keep "existing" from Jellyfin's point of view, prevent
// stale subtitle stream metadata from ever being cleared once the .sub is gone).
HashSet<string>? pairedVobSubKeys = _type == DlnaProfileType.Subtitle
? GetPairedVobSubKeys(files)
: null;
var externalPathInfos = new List<ExternalPathParserResult>();
ReadOnlySpan<char> prefix = video.FileNameWithoutExtension;
foreach (var file in files)
{
if (IsSuppressedVobSubFile(file, pairedVobSubKeys))
{
continue;
}
var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(file.AsSpan());
if (fileNameWithoutExtension.Length >= prefix.Length
&& prefix.Equals(fileNameWithoutExtension[..prefix.Length], StringComparison.OrdinalIgnoreCase)
@@ -304,6 +322,77 @@ namespace MediaBrowser.Providers.MediaInfo
return externalPathInfos;
}
/// <summary>
/// Determines whether a candidate file is part of a VobSub .idx/.sub pair that
/// should be resolved to only its .idx file, or an .idx file with no paired .sub
/// that cannot be probed at all.
/// </summary>
/// <param name="file">The full path to the candidate file.</param>
/// <param name="pairedVobSubKeys">The set of pairing keys with both an .idx and .sub present, or null if not applicable.</param>
/// <returns><c>true</c> if the file should be suppressed; otherwise, <c>false</c>.</returns>
private static bool IsSuppressedVobSubFile(string file, HashSet<string>? pairedVobSubKeys)
{
if (pairedVobSubKeys is null)
{
return false;
}
var extension = Path.GetExtension(file.AsSpan());
if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
{
// A paired .idx exists; probe it instead of the .sub payload.
return pairedVobSubKeys.Contains(GetVobSubPairingKey(file));
}
if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase))
{
// Without its .sub payload, the .idx cannot be probed for any data.
return !pairedVobSubKeys.Contains(GetVobSubPairingKey(file));
}
return false;
}
/// <summary>
/// Builds the set of directory+basename keys that have both an .idx and a .sub
/// file present, in a single pass over the candidate files.
/// </summary>
/// <param name="files">The candidate files to search.</param>
/// <returns>The set of pairing keys with both an .idx and .sub present.</returns>
private static HashSet<string> GetPairedVobSubKeys(IEnumerable<string> files)
{
var idxKeys = new HashSet<string>(StringComparer.Ordinal);
var subKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var file in files)
{
var extension = Path.GetExtension(file.AsSpan());
if (extension.Equals(".idx", StringComparison.OrdinalIgnoreCase))
{
idxKeys.Add(GetVobSubPairingKey(file));
}
else if (extension.Equals(".sub", StringComparison.OrdinalIgnoreCase))
{
subKeys.Add(GetVobSubPairingKey(file));
}
}
idxKeys.IntersectWith(subKeys);
return idxKeys;
}
/// <summary>
/// Builds a directory+basename key used to pair a VobSub .idx file with its .sub
/// payload only when both live in the same directory.
/// </summary>
/// <param name="file">The full path to the file.</param>
/// <returns>A key combining the containing directory and file name without extension.</returns>
private static string GetVobSubPairingKey(string file)
{
var directory = Path.GetDirectoryName(file) ?? string.Empty;
var baseName = Path.GetFileNameWithoutExtension(file);
return Path.Combine(directory, baseName);
}
/// <summary>
/// Returns the media info of the given file.
/// </summary>
@@ -163,12 +163,15 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies
// Caller provides the filename with extension stripped and NOT the parsed filename
var parsedName = _libraryManager.ParseName(info.Name);
var cleanedName = TmdbUtils.CleanName(parsedName.Name);
var searchYear = info.Year ?? parsedName.Year ?? 0;
var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, searchYear, info.MetadataLanguage, info.MetadataCountryCode, cancellationToken).ConfigureAwait(false);
if (searchResults?.Count > 0)
var match = TmdbUtils.FindBestMatch(searchResults, parsedName.Name, searchYear);
if (match is not null)
{
tmdbId = searchResults[0].Id;
tmdbId = match.Id;
}
}
@@ -58,7 +58,7 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie>
}
var providerName = MetadataProvider.Tmdb.ToString();
var page = 0;
var page = 1;
var totalPages = 1;
while (page <= totalPages && !cancellationToken.IsCancellationRequested)
@@ -67,12 +67,12 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie>
try
{
(pageResults, totalPages) = await _tmdbClientManager
.GetMovieSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
.GetMovieRecommendationsPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get similar movies from TMDb for {TmdbId} page {Page}", tmdbId, page);
_logger.LogWarning(ex, "Failed to get recommended movies from TMDb for {TmdbId} page {Page}", tmdbId, page);
yield break;
}
@@ -81,12 +81,12 @@ public class TmdbMovieSimilarProvider : IRemoteSimilarItemsProvider<Movie>
yield break;
}
foreach (var similar in pageResults)
foreach (var recommendation in pageResults)
{
yield return new SimilarItemReference
{
ProviderName = providerName,
ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture)
ProviderId = recommendation.Id.ToString(CultureInfo.InvariantCulture)
};
}
@@ -202,11 +202,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
// Caller provides the filename with extension stripped and NOT the parsed filename
var parsedName = _libraryManager.ParseName(info.Name);
var cleanedName = TmdbUtils.CleanName(parsedName.Name);
var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, info.Year ?? parsedName.Year ?? 0, cancellationToken).ConfigureAwait(false);
var searchYear = info.Year ?? parsedName.Year ?? 0;
var searchResults = await _tmdbClientManager.SearchSeriesAsync(cleanedName, info.MetadataLanguage, info.MetadataCountryCode, searchYear, cancellationToken).ConfigureAwait(false);
if (searchResults?.Count > 0)
var match = TmdbUtils.FindBestMatch(searchResults, parsedName.Name, searchYear);
if (match is not null)
{
tmdbId = searchResults[0].Id.ToString(CultureInfo.InvariantCulture);
tmdbId = match.Id.ToString(CultureInfo.InvariantCulture);
}
}
@@ -67,12 +67,12 @@ public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series>
try
{
(pageResults, totalPages) = await _tmdbClientManager
.GetSeriesSimilarPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
.GetSeriesRecommendationsPageAsync(tmdbId, page, TmdbUtils.GetImageLanguagesParam(string.Empty), cancellationToken)
.ConfigureAwait(false);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get similar TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page);
_logger.LogWarning(ex, "Failed to get recommended TV shows from TMDb for {TmdbId} page {Page}", tmdbId, page);
yield break;
}
@@ -81,12 +81,12 @@ public class TmdbSeriesSimilarProvider : IRemoteSimilarItemsProvider<Series>
yield break;
}
foreach (var similar in pageResults)
foreach (var recommendation in pageResults)
{
yield return new SimilarItemReference
{
ProviderName = providerName,
ProviderId = similar.Id.ToString(CultureInfo.InvariantCulture)
ProviderId = recommendation.Id.ToString(CultureInfo.InvariantCulture)
};
}
@@ -25,16 +25,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
{
private const int CacheDurationInHours = 1;
private readonly IMemoryCache _memoryCache;
// Sized in TMDb records - see EstimateSize - rather than in responses, because the responses
// differ in weight by orders of magnitude.
private const int CacheSizeLimit = 100_000;
private readonly MemoryCache _memoryCache;
private readonly TMDbClient _tmDbClient;
/// <summary>
/// Initializes a new instance of the <see cref="TmdbClientManager"/> class.
/// </summary>
/// <param name="memoryCache">An instance of <see cref="IMemoryCache"/>.</param>
public TmdbClientManager(IMemoryCache memoryCache)
public TmdbClientManager()
{
_memoryCache = memoryCache;
_memoryCache = new MemoryCache(new MemoryCacheOptions { SizeLimit = CacheSizeLimit });
var apiKey = Plugin.Instance.Configuration.TmdbApiKey;
apiKey = string.IsNullOrEmpty(apiKey) ? TmdbUtils.ApiKey : apiKey;
@@ -78,7 +81,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (movie is not null)
{
_memoryCache.Set(key, movie, TimeSpan.FromHours(CacheDurationInHours));
Cache(key, movie);
}
return movie;
@@ -112,7 +115,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (collection is not null)
{
_memoryCache.Set(key, collection, TimeSpan.FromHours(CacheDurationInHours));
Cache(key, collection);
}
return collection;
@@ -152,7 +155,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (series is not null)
{
_memoryCache.Set(key, series, TimeSpan.FromHours(CacheDurationInHours));
Cache(key, series);
}
return series;
@@ -208,7 +211,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (group is not null)
{
_memoryCache.Set(key, group, TimeSpan.FromHours(CacheDurationInHours));
Cache(key, group);
}
return group;
@@ -244,7 +247,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (season is not null)
{
_memoryCache.Set(key, season, TimeSpan.FromHours(CacheDurationInHours));
Cache(key, season);
}
return season;
@@ -296,7 +299,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (episode is not null)
{
_memoryCache.Set(key, episode, TimeSpan.FromHours(CacheDurationInHours));
Cache(key, episode);
}
return episode;
@@ -328,7 +331,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (person is not null)
{
_memoryCache.Set(key, person, TimeSpan.FromHours(CacheDurationInHours));
Cache(key, person);
}
return person;
@@ -366,7 +369,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (result is not null)
{
_memoryCache.Set(key, result, TimeSpan.FromHours(CacheDurationInHours));
Cache(key, result);
}
return result;
@@ -397,7 +400,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (searchResults?.Results?.Count > 0)
{
_memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours));
CacheSearch(key, searchResults);
}
return searchResults?.Results;
@@ -425,7 +428,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (searchResults?.Results?.Count > 0)
{
_memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours));
CacheSearch(key, searchResults);
}
return searchResults?.Results;
@@ -468,7 +471,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (searchResults?.Results?.Count > 0)
{
_memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours));
CacheSearch(key, searchResults);
}
return searchResults?.Results;
@@ -498,26 +501,26 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
if (searchResults?.Results?.Count > 0)
{
_memoryCache.Set(key, searchResults, TimeSpan.FromHours(CacheDurationInHours));
CacheSearch(key, searchResults);
}
return searchResults?.Results;
}
/// <summary>
/// Gets a single page of similar movies for a movie from the TMDb API.
/// Gets a single page of recommended movies for a movie from the TMDb API.
/// </summary>
/// <param name="tmdbId">The TMDb id of the movie.</param>
/// <param name="page">The page number to fetch (1-based).</param>
/// <param name="language">The language for results.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A tuple containing the list of similar movies and the total number of pages available.</returns>
public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
/// <returns>A tuple containing the list of recommended movies and the total number of pages available.</returns>
public async Task<(IReadOnlyList<SearchMovie> Results, int TotalPages)> GetMovieRecommendationsPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
{
await EnsureClientConfigAsync().ConfigureAwait(false);
var searchResults = await _tmDbClient
.GetMovieSimilarAsync(tmdbId, language, page, cancellationToken)
.GetMovieRecommendationsAsync(tmdbId, language, page, cancellationToken)
.ConfigureAwait(false);
if (searchResults?.Results is null || searchResults.Results.Count == 0)
@@ -529,19 +532,19 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
}
/// <summary>
/// Gets a single page of similar TV shows for a series from the TMDb API.
/// Gets a single page of recommended TV shows for a series from the TMDb API.
/// </summary>
/// <param name="tmdbId">The TMDb id of the TV show.</param>
/// <param name="page">The page number to fetch (1-based).</param>
/// <param name="language">The language for results.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A tuple containing the list of similar TV shows and the total number of pages available.</returns>
public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesSimilarPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
/// <returns>A tuple containing the list of recommended TV shows and the total number of pages available.</returns>
public async Task<(IReadOnlyList<SearchTv> Results, int TotalPages)> GetSeriesRecommendationsPageAsync(int tmdbId, int page, string? language, CancellationToken cancellationToken)
{
await EnsureClientConfigAsync().ConfigureAwait(false);
var searchResults = await _tmDbClient
.GetTvShowSimilarAsync(tmdbId, language, page, cancellationToken)
.GetTvShowRecommendationsAsync(tmdbId, language, page, cancellationToken)
.ConfigureAwait(false);
if (searchResults?.Results is null || searchResults.Results.Count == 0)
@@ -753,6 +756,84 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
return _tmDbClient.Config;
}
/// <summary>
/// Stores a response under the shared expiry, weighed by what it costs to keep.
/// </summary>
private void Cache<T>(string key, T value)
where T : class
=> _memoryCache.Set(
key,
value,
new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(CacheDurationInHours),
Size = EstimateSize(value)
});
/// <summary>
/// Stores a page of search results, whose weight is simply how many there are.
/// </summary>
private void CacheSearch<T>(string key, SearchContainer<T> results)
=> _memoryCache.Set(
key,
results,
new MemoryCacheEntryOptions
{
AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(CacheDurationInHours),
Size = 1 + Count(results.Results)
});
private static long Count<T>(IReadOnlyCollection<T>? items) => items?.Count ?? 0;
/// <summary>
/// Estimates what keeping a response costs, counting the sub-records that dominate it.
/// </summary>
private static long EstimateSize(object? value) => value switch
{
TvShow series => 1
+ Count(series.Credits?.Cast) + Count(series.Credits?.Crew)
+ EstimateAggregateSize(series.AggregateCredits)
+ Count(series.Seasons),
TvSeason season => 1
+ Count(season.Credits?.Cast) + Count(season.Credits?.Crew)
+ Count(season.Episodes),
TvEpisode episode => 1
+ Count(episode.Credits?.Cast) + Count(episode.Credits?.Crew)
+ Count(episode.Credits?.GuestStars),
Movie movie => 1 + Count(movie.Credits?.Cast) + Count(movie.Credits?.Crew),
Collection collection => 1 + Count(collection.Parts),
TvGroupCollection groups => 1 + Count(groups.Groups),
FindContainer found => 1
+ Count(found.MovieResults) + Count(found.TvResults)
+ Count(found.PersonResults) + Count(found.TvEpisode) + Count(found.TvSeason),
_ => 1
};
/// <summary>
/// Weighs aggregate credits, where each person carries one record per episode they worked on.
/// </summary>
private static long EstimateAggregateSize(CreditsAggregate? credits)
{
if (credits is null)
{
return 0;
}
var size = Count(credits.Cast) + Count(credits.Crew);
foreach (var cast in credits.Cast ?? [])
{
size += Count(cast.Roles);
}
foreach (var crew in credits.Crew ?? [])
{
size += Count(crew.Jobs);
}
return size;
}
/// <inheritdoc />
public void Dispose()
{
@@ -768,7 +849,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
{
if (disposing)
{
_memoryCache?.Dispose();
_memoryCache.Dispose();
_tmDbClient?.Dispose();
}
}
@@ -8,6 +8,7 @@ using System.Text.RegularExpressions;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Entities;
using TMDbLib.Objects.General;
using TMDbLib.Objects.Search;
using TMDbLib.Objects.TvShows;
using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo;
@@ -33,6 +34,11 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// </summary>
public const string ApiKey = "4219e299c89411838049ab0dab19ebd5";
private const int TitleExactScore = 8;
private const int TitlePrefixScore = 4;
private const int YearExactScore = 2;
private const int YearAdjacentScore = 1;
/// <summary>
/// The crew types to keep.
/// </summary>
@@ -63,8 +69,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
"novel"
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
[GeneratedRegex(@"[\W_-[·]]+")]
private static partial Regex NonWordRegex();
/// <summary>
/// Everything that is not a letter, a number or a combining mark separates two search terms. The
/// interpunct is kept because TMDb uses it inside titles such as "WALL·E", where it matches better
/// than a space does.
/// </summary>
[GeneratedRegex(@"[^\p{L}\p{N}\p{M}·]+")]
private static partial Regex NonSearchTermRegex();
/// <summary>
/// As <see cref="NonSearchTermRegex"/>, but the interpunct is a separator too, so a "WALL-E" folder
/// and the "WALL·E" title TMDb returns compare equal.
/// </summary>
[GeneratedRegex(@"[^\p{L}\p{N}\p{M}]+")]
private static partial Regex NonComparableRegex();
/// <summary>
/// Gets the TMDb id of an item, if it has one TMDb can be queried with.
@@ -101,7 +119,140 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
public static string CleanName(string name)
{
// TMDb expects a space separated list of words make sure that is the case
return NonWordRegex().Replace(name, " ");
return NonSearchTermRegex().Replace(name, " ").Trim();
}
/// <summary>
/// Reduces a title to the form used to compare a local name against a TMDb search result.
/// </summary>
/// <param name="title">The title to normalize.</param>
/// <returns>The normalized title, or an empty string if there was nothing to normalize.</returns>
public static string NormalizeTitle(string? title)
{
return string.IsNullOrEmpty(title)
? string.Empty
: NonComparableRegex().Replace(title, " ").Trim().ToLowerInvariant();
}
/// <summary>
/// Picks the movie search result that best matches the name and year an item was looked up by.
/// </summary>
/// <param name="results">The search results, in the order TMDb returned them.</param>
/// <param name="name">The parsed name of the local item.</param>
/// <param name="year">The year of the local item, or 0 if it is unknown.</param>
/// <returns>The best match, or <c>null</c> if there were no results.</returns>
public static SearchMovie? FindBestMatch(IReadOnlyList<SearchMovie>? results, string? name, int year)
{
return FindBestMatch(
results,
name,
year,
static movie => movie.Title,
static movie => movie.OriginalTitle,
static movie => movie.ReleaseDate);
}
/// <summary>
/// Picks the series search result that best matches the name and year an item was looked up by.
/// </summary>
/// <param name="results">The search results, in the order TMDb returned them.</param>
/// <param name="name">The parsed name of the local item.</param>
/// <param name="year">The year of the local item, or 0 if it is unknown.</param>
/// <returns>The best match, or <c>null</c> if there were no results.</returns>
public static SearchTv? FindBestMatch(IReadOnlyList<SearchTv>? results, string? name, int year)
{
return FindBestMatch(
results,
name,
year,
static series => series.Name,
static series => series.OriginalName,
static series => series.FirstAirDate);
}
/// <summary>
/// Picks the search result that best matches the name and year an item was looked up by.
/// </summary>
/// <remarks>
/// TMDb's year parameter only nudges relevance, it does not filter, so the first hit is regularly a
/// different film or show that happens to share the title - searching for "Mulan" with year 2020
/// returns the 1998 film first. A title that matches outranks one that does not, and the year only
/// separates candidates that are otherwise equally good. When nothing matches at all TMDb's own
/// ordering is kept, so a name that needs fuzzy matching, such as "A Christmas No. 1" for
/// "A Christmas Number One", still resolves.
/// </remarks>
private static T? FindBestMatch<T>(
IReadOnlyList<T>? results,
string? name,
int year,
Func<T, string?> titleSelector,
Func<T, string?> originalTitleSelector,
Func<T, DateTime?> releaseDateSelector)
where T : class
{
if (results is null || results.Count == 0)
{
return null;
}
var normalizedName = NormalizeTitle(name);
if (normalizedName.Length == 0)
{
return results[0];
}
var best = results[0];
var bestScore = 0;
foreach (var result in results)
{
var score = Math.Max(
ScoreTitle(normalizedName, titleSelector(result)),
ScoreTitle(normalizedName, originalTitleSelector(result)))
+ ScoreYear(year, releaseDateSelector(result)?.Year);
// Strictly greater, so ties keep the earlier, more relevant result.
if (score > bestScore)
{
bestScore = score;
best = result;
}
}
return best;
}
private static int ScoreTitle(string normalizedName, string? title)
{
var normalizedTitle = NormalizeTitle(title);
if (string.Equals(normalizedName, normalizedTitle, StringComparison.Ordinal))
{
return TitleExactScore;
}
// Whole words only, otherwise "Wall" half matches "Wall Street".
return normalizedTitle.Length > normalizedName.Length
&& normalizedTitle[normalizedName.Length] == ' '
&& normalizedTitle.StartsWith(normalizedName, StringComparison.Ordinal)
? TitlePrefixScore
: 0;
}
private static int ScoreYear(int year, int? resultYear)
{
if (year <= 0 || resultYear is not int candidateYear)
{
return 0;
}
return Math.Abs(candidateYear - year) switch
{
0 => YearExactScore,
// Regional release dates routinely straddle a new year.
1 => YearAdjacentScore,
_ => 0
};
}
/// <summary>
@@ -33,6 +33,7 @@ namespace MediaBrowser.Providers.Subtitles
private readonly ILibraryMonitor _monitor;
private readonly IMediaSourceManager _mediaSourceManager;
private readonly ILocalizationManager _localization;
private readonly IDirectoryService _directoryService;
private readonly HashSet<string> _allowedSubtitleFormats;
private readonly ISubtitleProvider[] _subtitleProviders;
@@ -43,6 +44,7 @@ namespace MediaBrowser.Providers.Subtitles
ILibraryMonitor monitor,
IMediaSourceManager mediaSourceManager,
ILocalizationManager localizationManager,
IDirectoryService directoryService,
IEnumerable<ISubtitleProvider> subtitleProviders,
NamingOptions namingOptions)
{
@@ -51,6 +53,7 @@ namespace MediaBrowser.Providers.Subtitles
_monitor = monitor;
_mediaSourceManager = mediaSourceManager;
_localization = localizationManager;
_directoryService = directoryService;
_subtitleProviders = subtitleProviders
.OrderBy(i => i is IHasOrder hasOrder ? hasOrder.Order : 0)
.ToArray();
@@ -281,6 +284,8 @@ namespace MediaBrowser.Providers.Subtitles
await stream.CopyToAsync(fs).ConfigureAwait(false);
}
_directoryService.Invalidate(path);
return;
}
else
@@ -395,6 +400,8 @@ namespace MediaBrowser.Providers.Subtitles
_monitor.ReportFileSystemChangeComplete(path, false);
}
_directoryService.Invalidate(path);
return item.RefreshMetadata(CancellationToken.None);
}
@@ -44,6 +44,31 @@ public class EpisodeMetadataService : MetadataService<Episode, EpisodeInfo>
{
var updatedType = base.BeforeSaveInternal(item, isFullRefresh, updateType);
// An episode cannot end before it starts.
if (item.IndexNumberEnd < item.IndexNumber)
{
Logger.LogWarning(
"Discarding episode range end {IndexNumberEnd} preceding episode number {IndexNumber} for {Path}",
item.IndexNumberEnd,
item.IndexNumber,
item.Path);
item.IndexNumberEnd = null;
updatedType |= ItemUpdateType.MetadataImport;
}
else if (item.IndexNumberEnd.HasValue && !item.IndexNumber.HasValue)
{
// Without a first episode the end does not describe a range. Promoting it to the episode number
// would invent an identity the metadata never supplied, so drop the orphaned value instead.
Logger.LogWarning(
"Discarding episode range end {IndexNumberEnd} without an episode number for {Path}",
item.IndexNumberEnd,
item.Path);
item.IndexNumberEnd = null;
updatedType |= ItemUpdateType.MetadataImport;
}
var seriesName = item.FindSeriesName();
if (!string.Equals(item.SeriesName, seriesName, StringComparison.Ordinal))
{
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading;
using System.Xml;
@@ -44,41 +46,60 @@ namespace MediaBrowser.XbmcMetadata.Parsers
var xmlFile = File.ReadAllText(metadataFile);
var srch = "</episodedetails>";
var index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase);
var xml = xmlFile;
if (index != -1)
// Split the nfo into its episodedetails blocks.
// This is needed because XBMC metadata uses multiple episodedetails blocks instead of an episodenumberend tag.
const string Srch = "</episodedetails>";
var blocks = new List<string>();
int index;
while ((index = xmlFile.IndexOf(Srch, StringComparison.OrdinalIgnoreCase)) != -1)
{
xml = xmlFile.Substring(0, index + srch.Length);
xmlFile = xmlFile.Substring(index + srch.Length);
blocks.Add(xmlFile.Substring(0, index + Srch.Length));
xmlFile = xmlFile.Substring(index + Srch.Length);
}
if (blocks.Count == 0)
{
// No closing tag, let the xml reader deal with whatever is in the file
blocks.Add(xmlFile);
}
// These are not going to be valid xml so no sense in causing the provider to fail and spamming the log with exceptions
try
{
// Extract episode details from the first episodedetails block
ReadEpisodeDetailsFromXml(item, xml, settings, cancellationToken);
if (blocks.Count == 1)
{
ReadEpisodeDetailsFromXml(item, blocks[0], settings, cancellationToken);
return;
}
// Extract the last episode number from nfo
// Retrieves all additional episodedetails blocks from the rest of the nfo and concatenates the name, originalTitle and overview tags with the first episode
// This is needed because XBMC metadata uses multiple episodedetails blocks instead of episodenumberend tag
// The blocks are not guaranteed to be written in ascending episode order, so parse them all
// and sort them before merging.
var episodes = blocks
.Select(block =>
{
var episode = new MetadataResult<Episode>()
{
Item = new Episode()
};
ReadEpisodeDetailsFromXml(episode, block, settings, cancellationToken);
return (Xml: block, Result: episode);
})
.OrderBy(episode => episode.Result.Item.IndexNumber ?? int.MaxValue)
.ToList();
// Extract the details of the lowest numbered episode into the item that is returned to the caller
ReadEpisodeDetailsFromXml(item, episodes[0].Xml, settings, cancellationToken);
// Concatenate the name, originalTitle and overview tags of the remaining episodes with the first one
// and take the highest episode number as the last episode of the file
var name = new StringBuilder(item.Item.Name);
var originalTitle = new StringBuilder(item.Item.OriginalTitle);
var overview = new StringBuilder(item.Item.Overview);
while ((index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase)) != -1)
for (var i = 1; i < episodes.Count; i++)
{
xml = xmlFile.Substring(0, index + srch.Length);
xmlFile = xmlFile.Substring(index + srch.Length);
var additionalEpisode = new MetadataResult<Episode>()
{
Item = new Episode()
};
// Extract episode details from additional episodedetails block
ReadEpisodeDetailsFromXml(additionalEpisode, xml, settings, cancellationToken);
var additionalEpisode = episodes[i].Result;
if (!string.IsNullOrEmpty(additionalEpisode.Item.Name))
{
-1
View File
@@ -27,7 +27,6 @@ jellyfin_subprojects=(
MediaBrowser.Model/MediaBrowser.Model.csproj
Emby.Naming/Emby.Naming.csproj
src/Jellyfin.Extensions/Jellyfin.Extensions.csproj
src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj
)
issue_template_file="./.github/ISSUE_TEMPLATE/issue report.yml"
@@ -37,14 +37,16 @@ public interface IJellyfinDatabaseProvider
void ConfigureConventions(ModelConfigurationBuilder configurationBuilder);
/// <summary>
/// If supported this should run any periodic maintaince tasks.
/// If supported this should run any periodic maintaince tasks, reclaiming unused space and refreshing the query
/// planner statistics. Also used after migrations have modified the database.
/// </summary>
/// <param name="cancellationToken">The token to abort the operation.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
Task RunScheduledOptimisation(CancellationToken cancellationToken);
/// <summary>
/// If supported this should perform any actions that are required on stopping the jellyfin server.
/// If supported this should perform any actions that are required on stopping the jellyfin server, including the
/// same maintenance as <see cref="RunScheduledOptimisation(CancellationToken)"/>.
/// </summary>
/// <param name="cancellationToken">The token that will be used to abort the operation.</param>
/// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns>
@@ -103,17 +103,9 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
}
/// <inheritdoc/>
public async Task RunScheduledOptimisation(CancellationToken cancellationToken)
public Task RunScheduledOptimisation(CancellationToken cancellationToken)
{
var context = await DbContextFactory!.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
_logger.LogInformation("jellyfin.db optimized successfully!");
}
return OptimizeAsync(cancellationToken);
}
/// <inheritdoc/>
@@ -124,20 +116,38 @@ public sealed class SqliteDatabaseProvider : IJellyfinDatabaseProvider
/// <inheritdoc/>
public async Task RunShutdownTask(CancellationToken cancellationToken)
{
// Run before disposing the application
try
{
await OptimizeAsync(cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// A missed optimization only costs performance, so never fail the shutdown over this.
_logger.LogError(ex, "Error while optimizing jellyfin.db");
}
SqliteConnection.ClearAllPools();
}
private async Task OptimizeAsync(CancellationToken cancellationToken)
{
if (DbContextFactory is null)
{
return;
}
// Run before disposing the application
var context = await DbContextFactory.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("PRAGMA analysis_limit=0", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("ANALYZE", cancellationToken).ConfigureAwait(false);
await context.Database.ExecuteSqlRawAsync("PRAGMA wal_checkpoint(TRUNCATE)", cancellationToken).ConfigureAwait(false);
_logger.LogInformation("jellyfin.db optimized successfully!");
}
SqliteConnection.ClearAllPools();
}
/// <inheritdoc/>
+78 -16
View File
@@ -1,8 +1,10 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using BlurHashSharp.SkiaSharp;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
@@ -22,22 +24,15 @@ namespace Jellyfin.Drawing.Skia;
public class SkiaEncoder : IImageEncoder
{
private const string SvgFormat = "svg";
// The light sharpening kernel applied after resizing, see ResizeImage.
private const float SharpenCenterWeight = 1.4f;
private const float SharpenNeighborWeight = -0.1f;
private static readonly HashSet<string> _transparentImageTypes = new(StringComparer.OrdinalIgnoreCase) { ".png", ".gif", ".webp" };
private readonly ILogger<SkiaEncoder> _logger;
private readonly IApplicationPaths _appPaths;
private static readonly SKTypeface?[] _typefaces = InitializeTypefaces();
private static readonly SKImageFilter _imageFilter = SKImageFilter.CreateMatrixConvolution(
new SKSizeI(3, 3),
[
0, -.1f, 0,
-.1f, 1.4f, -.1f,
0, -.1f, 0
],
1f,
0f,
new SKPointI(1, 1),
SKShaderTileMode.Clamp,
true);
/// <summary>
/// The default sampling options, equivalent to old high quality filter settings when upscaling.
@@ -561,8 +556,8 @@ public class SkiaEncoder : IImageEncoder
/// <returns>The resized image.</returns>
internal static SKImage ResizeImage(SKBitmap source, SKImageInfo targetInfo, bool isAntialias = false, bool isDither = false)
{
using var surface = SKSurface.Create(targetInfo);
using var canvas = surface.Canvas;
using var target = new SKBitmap(targetInfo);
using var canvas = new SKCanvas(target);
using var paint = new SKPaint();
paint.IsAntialias = isAntialias;
paint.IsDither = isDither;
@@ -574,7 +569,6 @@ public class SkiaEncoder : IImageEncoder
? DefaultSamplingOptions
: UpscaleSamplingOptions;
paint.ImageFilter = _imageFilter;
canvas.DrawBitmap(
source,
SKRect.Create(0, 0, source.Width, source.Height),
@@ -582,7 +576,75 @@ public class SkiaEncoder : IImageEncoder
samplingOptions,
paint);
return surface.Snapshot();
SharpenInPlace(target);
return SKImage.FromBitmap(target);
}
/// <summary>
/// Applies the light 3x3 sharpening kernel to the bitmap in place.
///
/// This is equivalent to the SKImageFilter.CreateMatrixConvolution paint filter that
/// was previously part of the resize draw call. Since the SkiaSharp 3 update that
/// filter no longer has a fast CPU path and takes multiple seconds per image on the
/// software rasterizer, so the same kernel is applied directly instead.
/// </summary>
/// <param name="bitmap">The bitmap to sharpen. Must use a color type with four bytes per pixel; other color types are returned unchanged.</param>
internal static void SharpenInPlace(SKBitmap bitmap)
{
if (bitmap.BytesPerPixel != 4)
{
return;
}
var width = bitmap.Width;
var height = bitmap.Height;
var stride = bitmap.RowBytes;
var pixels = bitmap.GetPixels();
if (width == 0 || height == 0 || pixels == IntPtr.Zero)
{
return;
}
var length = stride * height;
var source = ArrayPool<byte>.Shared.Rent(length);
var result = ArrayPool<byte>.Shared.Rent(length);
try
{
Marshal.Copy(pixels, source, 0, length);
for (var y = 0; y < height; y++)
{
// The kernel clamps at the edges: out-of-bounds taps reuse the edge pixel.
var row = y * stride;
var up = y == 0 ? row : row - stride;
var down = y == height - 1 ? row : row + stride;
for (var x = 0; x < width; x++)
{
var col = x * 4;
var left = x == 0 ? col : col - 4;
var right = x == width - 1 ? col : col + 4;
for (var channel = 0; channel < 4; channel++)
{
var value = (SharpenCenterWeight * source[row + col + channel])
+ (SharpenNeighborWeight * (source[up + col + channel]
+ source[down + col + channel]
+ source[row + left + channel]
+ source[row + right + channel]));
result[row + col + channel] = (byte)Math.Clamp((int)(value + 0.5f), 0, 255);
}
}
}
Marshal.Copy(result, 0, pixels, length);
}
finally
{
ArrayPool<byte>.Shared.Return(source);
ArrayPool<byte>.Shared.Return(result);
}
}
/// <inheritdoc/>
+25 -1
View File
@@ -32,6 +32,7 @@ namespace Jellyfin.LiveTv.TunerHosts
{
private static readonly string[] _mimeTypesCanShareHttpStream = ["video/MP2T"];
private static readonly string[] _extensionsCanShareHttpStream = [".ts", ".tsv", ".m2t"];
private static readonly string[] _manifestExtensions = [".m3u8", ".m3u", ".mpd"];
private readonly IHttpClientFactory _httpClientFactory;
private readonly IServerApplicationHost _appHost;
@@ -151,11 +152,20 @@ namespace Jellyfin.LiveTv.TunerHosts
var protocol = _mediaSourceManager.GetPathProtocol(path);
var isRemote = true;
if (Uri.TryCreate(path, UriKind.Absolute, out var uri))
Uri.TryCreate(path, UriKind.Absolute, out var uri);
if (uri is not null)
{
isRemote = !_networkManager.IsInLocalNetwork(uri.Host);
}
// A manifest is not a byte stream. Serving one directly hands the client a playlist whose
// variant and segment URIs are relative to the origin, and those do not resolve against the
// Jellyfin url the client fetched it from. Remux or transcode these instead.
if (IsManifest(path, uri))
{
supportsDirectPlay = false;
}
var httpHeaders = new Dictionary<string, string>();
if (protocol == MediaProtocol.Http)
@@ -210,6 +220,20 @@ namespace Jellyfin.LiveTv.TunerHosts
return mediaSource;
}
/// <summary>
/// Determines whether a channel path points at an HLS or DASH manifest rather than at a byte stream.
/// </summary>
/// <param name="path">The channel path.</param>
/// <param name="uri">The channel path parsed as an absolute uri, or <c>null</c> if it is not one.</param>
/// <returns><c>true</c> if the path names a streaming manifest.</returns>
private static bool IsManifest(string path, Uri uri)
{
// Use the uri path when there is one so that a query string does not hide the extension.
var extension = Path.GetExtension(uri is null ? path : uri.AbsolutePath);
return _manifestExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase);
}
public Task<List<TunerHostInfo>> DiscoverDevices(int discoveryDurationMs, CancellationToken cancellationToken)
{
return Task.FromResult(new List<TunerHostInfo>());
@@ -1,3 +1,5 @@
using System.Globalization;
using System.IO;
using System.Linq;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.IO;
@@ -8,29 +10,31 @@ namespace Jellyfin.Controller.Tests
{
public class DirectoryServiceTests
{
private const string LowerCasePath = "/music/someartist";
private const string UpperCasePath = "/music/SOMEARTIST";
// Path.GetDirectoryName, which Invalidate uses to find the parent, normalizes the
// separators, so cache keys only match the parent it returns when they use the platform's.
private static readonly string _lowerCasePath = LocalPath("/music/someartist");
private static readonly string _upperCasePath = LocalPath("/music/SOMEARTIST");
private static readonly FileSystemMetadata[] _lowerCaseFileSystemMetadata =
{
new()
{
FullName = LowerCasePath + "/Artwork",
FullName = Path.Combine(_lowerCasePath, "Artwork"),
IsDirectory = true
},
new()
{
FullName = LowerCasePath + "/Some Other Folder",
FullName = Path.Combine(_lowerCasePath, "Some Other Folder"),
IsDirectory = true
},
new()
{
FullName = LowerCasePath + "/Song 2.mp3",
FullName = Path.Combine(_lowerCasePath, "Song 2.mp3"),
IsDirectory = false
},
new()
{
FullName = LowerCasePath + "/Song 3.mp3",
FullName = Path.Combine(_lowerCasePath, "Song 3.mp3"),
IsDirectory = false
}
};
@@ -39,12 +43,12 @@ namespace Jellyfin.Controller.Tests
{
new()
{
FullName = UpperCasePath + "/Lyrics",
FullName = Path.Combine(_upperCasePath, "Lyrics"),
IsDirectory = true
},
new()
{
FullName = UpperCasePath + "/Song 1.mp3",
FullName = Path.Combine(_upperCasePath, "Song 1.mp3"),
IsDirectory = false
}
};
@@ -53,12 +57,12 @@ namespace Jellyfin.Controller.Tests
public void GetFileSystemEntries_GivenPathsWithDifferentCasing_CachesAll()
{
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
var upperCaseResult = directoryService.GetFileSystemEntries(UpperCasePath);
var lowerCaseResult = directoryService.GetFileSystemEntries(LowerCasePath);
var upperCaseResult = directoryService.GetFileSystemEntries(_upperCasePath);
var lowerCaseResult = directoryService.GetFileSystemEntries(_lowerCasePath);
Assert.Equal(_upperCaseFileSystemMetadata, upperCaseResult);
Assert.Equal(_lowerCaseFileSystemMetadata, lowerCaseResult);
@@ -68,12 +72,12 @@ namespace Jellyfin.Controller.Tests
public void GetFiles_GivenPathsWithDifferentCasing_ReturnsCorrectFiles()
{
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
var upperCaseResult = directoryService.GetFiles(UpperCasePath);
var lowerCaseResult = directoryService.GetFiles(LowerCasePath);
var upperCaseResult = directoryService.GetFiles(_upperCasePath);
var lowerCaseResult = directoryService.GetFiles(_lowerCasePath);
Assert.Equal(_upperCaseFileSystemMetadata.Where(f => !f.IsDirectory), upperCaseResult);
Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => !f.IsDirectory), lowerCaseResult);
@@ -83,12 +87,12 @@ namespace Jellyfin.Controller.Tests
public void GetDirectories_GivenPathsWithDifferentCasing_ReturnsCorrectDirectories()
{
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == UpperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == LowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _upperCasePath), false)).Returns(_upperCaseFileSystemMetadata);
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.Is<string>(x => x == _lowerCasePath), false)).Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
var upperCaseResult = directoryService.GetDirectories(UpperCasePath);
var lowerCaseResult = directoryService.GetDirectories(LowerCasePath);
var upperCaseResult = directoryService.GetDirectories(_upperCasePath);
var lowerCaseResult = directoryService.GetDirectories(_lowerCasePath);
Assert.Equal(_upperCaseFileSystemMetadata.Where(f => f.IsDirectory), upperCaseResult);
Assert.Equal(_lowerCaseFileSystemMetadata.Where(f => f.IsDirectory), lowerCaseResult);
@@ -248,5 +252,171 @@ namespace Jellyfin.Controller.Tests
Assert.Equal(cachedPaths, result);
Assert.Equal(newPaths, secondResult);
}
[Fact]
public void GetFileSystemEntries_RepeatedPath_ReadsTheFileSystemOnce()
{
var fileSystemMock = new Mock<IFileSystem>(MockBehavior.Strict);
fileSystemMock.Setup(f => f.GetFileSystemEntries(_lowerCasePath))
.Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
directoryService.GetFileSystemEntries(_lowerCasePath);
directoryService.GetFileSystemEntries(_lowerCasePath);
fileSystemMock.Verify(f => f.GetFileSystemEntries(_lowerCasePath), Times.Once);
}
[Fact]
public void Invalidate_GivenADirectory_DropsBothTheListingAndTheFilePaths()
{
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(_lowerCasePath))
.Returns(_lowerCaseFileSystemMetadata)
.Returns(_upperCaseFileSystemMetadata);
fileSystemMock.SetupSequence(f => f.GetFilePaths(_lowerCasePath, false))
.Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3") })
.Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3"), Path.Combine(_lowerCasePath, "Song 2.srt") });
var directoryService = new DirectoryService(fileSystemMock.Object);
directoryService.GetFileSystemEntries(_lowerCasePath);
directoryService.GetFilePaths(_lowerCasePath);
directoryService.Invalidate(_lowerCasePath);
Assert.Equal(_upperCaseFileSystemMetadata, directoryService.GetFileSystemEntries(_lowerCasePath));
Assert.Equal(2, directoryService.GetFilePaths(_lowerCasePath).Count);
}
[Fact]
public void Invalidate_GivenAFile_DropsTheListingOfTheDirectoryHoldingIt()
{
var newFile = Path.Combine(_lowerCasePath, "Song 2.srt");
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.SetupSequence(f => f.GetFileSystemEntries(_lowerCasePath))
.Returns(_lowerCaseFileSystemMetadata)
.Returns(_upperCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
directoryService.GetFileSystemEntries(_lowerCasePath);
directoryService.Invalidate(newFile);
Assert.Equal(_upperCaseFileSystemMetadata, directoryService.GetFileSystemEntries(_lowerCasePath));
}
[Fact]
public void GetFilePaths_ClearingTheCache_KeepsTheParentDirectory()
{
var parentPath = LocalPath("/music");
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.Setup(f => f.GetFilePaths(_lowerCasePath))
.Returns(new[] { Path.Combine(_lowerCasePath, "Song 2.mp3") });
fileSystemMock.Setup(f => f.GetFileSystemEntries(parentPath))
.Returns(_lowerCaseFileSystemMetadata);
var directoryService = new DirectoryService(fileSystemMock.Object);
directoryService.GetFileSystemEntries(parentPath);
directoryService.GetFilePaths(_lowerCasePath, true);
directoryService.GetFileSystemEntries(parentPath);
fileSystemMock.Verify(f => f.GetFileSystemEntries(parentPath), Times.Once);
}
[Fact]
public void GetFileSystemEntries_MoreRecordsThanTheCeiling_DropsCache()
{
// Charged by the files in a listing, not the number of listings, so a few big folders
// reach the limit where a lot of small ones would not.
const int FolderCount = 60;
var bigListing = new FileSystemMetadata[5000];
for (var i = 0; i < bigListing.Length; i++)
{
bigListing[i] = new FileSystemMetadata
{
FullName = "/music/track" + i.ToString(CultureInfo.InvariantCulture),
IsDirectory = false
};
}
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.IsAny<string>()))
.Returns(bigListing);
var directoryService = new DirectoryService(fileSystemMock.Object);
const string FirstPath = "/music/artist0";
directoryService.GetFileSystemEntries(FirstPath);
for (var i = 1; i < FolderCount; i++)
{
directoryService.GetFileSystemEntries("/music/artist" + i.ToString(CultureInfo.InvariantCulture));
}
directoryService.GetFileSystemEntries(FirstPath);
fileSystemMock.Verify(f => f.GetFileSystemEntries(FirstPath), Times.Exactly(2));
}
[Fact]
public void GetFileSystemEntries_RepeatedlyInvalidatedFolder_KeepsUnrelatedEntriesCached()
{
// Invalidating gives the records back, so churning one folder must not add up to the
// ceiling and drop everything else with it.
const int ChurnCount = 50;
var bigListing = new FileSystemMetadata[5000];
for (var i = 0; i < bigListing.Length; i++)
{
bigListing[i] = new FileSystemMetadata
{
FullName = "/music/track" + i.ToString(CultureInfo.InvariantCulture),
IsDirectory = false
};
}
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.Setup(f => f.GetFileSystemEntries(It.IsAny<string>()))
.Returns(bigListing);
var directoryService = new DirectoryService(fileSystemMock.Object);
const string ChurnedPath = "/music/watched";
const string StablePath = "/music/untouched";
directoryService.GetFileSystemEntries(StablePath);
for (var i = 0; i < ChurnCount; i++)
{
directoryService.GetFileSystemEntries(ChurnedPath);
directoryService.Invalidate(ChurnedPath);
}
directoryService.GetFileSystemEntries(StablePath);
fileSystemMock.Verify(f => f.GetFileSystemEntries(StablePath), Times.Once);
}
[Fact]
public void GetFileSystemEntry_MissingPath_IsNotRemembered()
{
const string MissingPath = "/music/not-here";
var fileSystemMock = new Mock<IFileSystem>();
fileSystemMock.SetupSequence(f => f.GetFileSystemInfo(MissingPath))
.Returns(new FileSystemMetadata { FullName = MissingPath, Exists = false })
.Returns(new FileSystemMetadata { FullName = MissingPath, Exists = true });
var directoryService = new DirectoryService(fileSystemMock.Object);
Assert.Null(directoryService.GetFileSystemEntry(MissingPath));
Assert.NotNull(directoryService.GetFileSystemEntry(MissingPath));
}
private static string LocalPath(string path)
=> path.Replace('/', Path.DirectorySeparatorChar);
}
}
@@ -0,0 +1,216 @@
using System;
using System.Collections.Concurrent;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.LibraryTaskScheduler;
using MediaBrowser.Model.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Controller.Tests.LibraryTaskScheduler
{
public class LimitedConcurrencyLibrarySchedulerTests
{
private static readonly TimeSpan _shortGracePeriod = TimeSpan.FromMilliseconds(50);
// Generous, because these only ever wait for something that should already have happened.
private static readonly TimeSpan _timeout = TimeSpan.FromSeconds(10);
[Fact]
public async Task Enqueue_ProcessesEveryItem()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping);
await using (scheduler)
{
var data = Enumerable.Range(0, 100).ToArray();
var processed = new ConcurrentBag<int>();
await scheduler.Enqueue(
data,
(item, _) =>
{
processed.Add(item);
return Task.CompletedTask;
},
new Progress<double>(),
CancellationToken.None);
Assert.Equal(data, processed.Order());
}
}
[Fact]
public async Task Enqueue_WithFailingWorker_StillCompletes()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping);
await using (scheduler)
{
await scheduler.Enqueue(
Enumerable.Range(0, 20).ToArray(),
(item, _) => item % 2 == 0 ? throw new InvalidOperationException("boom") : Task.CompletedTask,
new Progress<double>(),
CancellationToken.None);
}
}
/// <summary>
/// The runners wait on a source linked to <see cref="IHostApplicationLifetime.ApplicationStopping"/>,
/// so a shutdown has to reach them. It does not travel from the linked source back to the one
/// the cleanup cancels, which is what made them immortal.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
public async Task ApplicationStopping_RetiresRunners()
{
using var appStopping = new CancellationTokenSource();
// Long enough that the cleanup cannot be what retires them.
var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5));
await using (scheduler)
{
await RunOneOperation(scheduler);
Assert.True(scheduler.ActiveRunnerCount > 0);
await appStopping.CancelAsync();
await WaitForAsync(() => scheduler.ActiveRunnerCount == 0);
}
}
/// <summary>
/// The cleanup used to be a one shot: it never released the scheduling slot it took, so
/// every runner spawned after the first pass stayed around for the lifetime of the server.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
public async Task Enqueue_RetiresIdleRunnersAfterEveryOperation()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping);
await using (scheduler)
{
for (var round = 0; round < 3; round++)
{
await RunOneOperation(scheduler);
Assert.True(scheduler.ActiveRunnerCount > 0, $"no runner spawned in round {round}");
await WaitForAsync(() => scheduler.ActiveRunnerCount == 0);
}
}
}
/// <summary>
/// Disposing used to sit out the rest of the cleanup grace period, holding up shutdown for
/// up to a minute.
/// </summary>
/// <returns>A <see cref="Task"/> representing the asynchronous unit test.</returns>
[Fact]
public async Task DisposeAsync_DoesNotWaitOutTheGracePeriod()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping, gracePeriod: TimeSpan.FromMinutes(5));
await RunOneOperation(scheduler);
var stopwatch = Stopwatch.StartNew();
await scheduler.DisposeAsync();
Assert.True(stopwatch.Elapsed < _timeout, $"disposing took {stopwatch.Elapsed}");
}
[Fact]
public async Task Enqueue_AfterDispose_DoesNothing()
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping);
await scheduler.DisposeAsync();
var processed = 0;
await scheduler.Enqueue(
Enumerable.Range(0, 10).ToArray(),
(_, _) =>
{
Interlocked.Increment(ref processed);
return Task.CompletedTask;
},
new Progress<double>(),
CancellationToken.None);
Assert.Equal(0, processed);
}
[Theory]
[InlineData(1)]
[InlineData(4)]
public async Task Enqueue_FromWithinAWorker_DoesNotDeadlock(int fanout)
{
using var appStopping = new CancellationTokenSource();
var scheduler = CreateScheduler(appStopping, fanout: fanout);
await using (scheduler)
{
var inner = 0;
var outer = scheduler.Enqueue(
Enumerable.Range(0, 8).ToArray(),
(_, _) => scheduler.Enqueue(
Enumerable.Range(0, 4).ToArray(),
(_, _) =>
{
Interlocked.Increment(ref inner);
return Task.CompletedTask;
},
new Progress<double>(),
CancellationToken.None),
new Progress<double>(),
CancellationToken.None);
await outer.WaitAsync(_timeout, TestContext.Current.CancellationToken);
Assert.Equal(32, inner);
}
}
private static LimitedConcurrencyLibraryScheduler CreateScheduler(
CancellationTokenSource appStopping,
int fanout = 4,
TimeSpan? gracePeriod = null)
{
var lifetime = new Mock<IHostApplicationLifetime>();
lifetime.SetupGet(x => x.ApplicationStopping).Returns(() => appStopping.Token);
var configurationManager = new Mock<IServerConfigurationManager>();
configurationManager.SetupGet(x => x.Configuration)
.Returns(new ServerConfiguration { LibraryScanFanoutConcurrency = fanout });
return new LimitedConcurrencyLibraryScheduler(
lifetime.Object,
NullLogger<LimitedConcurrencyLibraryScheduler>.Instance,
configurationManager.Object,
gracePeriod ?? _shortGracePeriod);
}
private static Task RunOneOperation(LimitedConcurrencyLibraryScheduler scheduler)
=> scheduler.Enqueue(
Enumerable.Range(0, 8).ToArray(),
(_, _) => Task.CompletedTask,
new Progress<double>(),
CancellationToken.None);
private static async Task WaitForAsync(Func<bool> condition)
{
var stopwatch = Stopwatch.StartNew();
while (!condition())
{
Assert.True(stopwatch.Elapsed < _timeout, "timed out waiting for the scheduler to settle");
await Task.Delay(20, TestContext.Current.CancellationToken);
}
}
}
}
@@ -0,0 +1,162 @@
using System;
using Jellyfin.Data.Enums;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Streaming;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using Moq;
using Xunit;
using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
namespace Jellyfin.Controller.Tests.MediaEncoding;
public class EncodingHelperDoviTests
{
[Theory]
[InlineData(null, false)]
[InlineData("bt709", false)]
[InlineData("unknown", false)]
[InlineData("bt2020-10", false)]
[InlineData("smpte2084", true)]
[InlineData("arib-std-b67", true)]
public void GetSwVidFilterChain_InvalidDovi_OnlyTonemapsHdrBaseLayer(string? transfer, bool tonemap)
{
var state = CreateState("hevc", transfer);
var helper = CreateHelper(true);
var (filters, _, _) = helper.GetSwVidFilterChain(state, new EncodingOptions(), "libx264");
var args = string.Join(',', filters);
Assert.Equal(VideoRangeType.DOVIInvalid, state.VideoStream.VideoRangeType);
Assert.Equal(tonemap, args.Contains("tonemapx=", StringComparison.Ordinal));
Assert.Contains(tonemap ? "color_trc=" + transfer : "color_trc=bt709", args, StringComparison.Ordinal);
}
[Theory]
[InlineData(null, false)]
[InlineData("bt709", false)]
[InlineData("arib-std-b67", false)]
[InlineData("smpte2084", true)]
[InlineData("SMPTE2084", true)]
public void IsDoviWithHdr10Bl_InvalidDovi_RequiresPq(string? transfer, bool expected)
{
var stream = CreateState("hevc", transfer).VideoStream;
Assert.True(EncodingHelper.IsDovi(stream));
Assert.Equal(expected, EncodingHelper.IsDoviWithHdr10Bl(stream));
}
[Theory]
[InlineData("hevc", null, "hevc_metadata=remove_dovi=1")]
[InlineData("hevc", "bt709", "hevc_metadata=remove_dovi=1")]
[InlineData("hevc", "smpte2084", "hevc_metadata=remove_dovi=1")]
[InlineData("hevc", "arib-std-b67", "hevc_metadata=remove_dovi=1")]
[InlineData("av1", null, "av1_metadata=remove_dovi=1")]
[InlineData("av1", "bt709", "av1_metadata=remove_dovi=1")]
[InlineData("av1", "smpte2084", "av1_metadata=remove_dovi=1")]
[InlineData("av1", "arib-std-b67", "av1_metadata=remove_dovi=1")]
public void GetBitStreamArgs_InvalidDovi_PreservesClientDependentRemoval(string codec, string? transfer, string expected)
{
var state = CreateState(codec, transfer);
var helper = CreateHelper(true);
foreach (var (requestedRanges, removeDovi) in new[] { (null, false), ("SDR", false), ("HDR10", false), ("DOVIWithEL", false), ("DOVI", true), ("SDR,DOVI", true) })
{
state.BaseRequest.VideoRangeType = requestedRanges;
Assert.Equal(removeDovi, helper.IsDoviRemoved(state));
if (removeDovi)
{
Assert.Contains(expected, helper.GetBitStreamArgs(state, MediaStreamType.Video), StringComparison.Ordinal);
}
else
{
Assert.Equal(codec == "hevc" ? "-bsf:v hevc_mp4toannexb" : null, helper.GetBitStreamArgs(state, MediaStreamType.Video));
}
Assert.False(CreateHelper(false).IsDoviRemoved(state));
}
}
[Theory]
[InlineData(null, true)]
[InlineData("HDR10", true)]
[InlineData("DOVI", false)]
[InlineData("SDR,DOVI", false)]
public void CanStreamCopyVideo_InvalidDovi_RequiresRemovalSupportOnlyForDoviClients(string? requestedRanges, bool copyWithoutRemovalSupport)
{
foreach (var codec in new[] { "hevc", "av1" })
{
foreach (var transfer in new[] { "bt709", "smpte2084" })
{
var state = CreateState(codec, transfer);
state.BaseRequest.VideoRangeType = requestedRanges;
Assert.True(CreateHelper(true).CanStreamCopyVideo(state, state.VideoStream));
Assert.Equal(copyWithoutRemovalSupport, CreateHelper(false).CanStreamCopyVideo(state, state.VideoStream));
}
}
}
[Fact]
public void GetBitStreamArgs_ValidDovi_PreservesMetadata()
{
var state = CreateState("hevc", "smpte2084");
state.VideoStream.ColorSpace = "bt2020nc";
state.VideoStream.ColorPrimaries = "bt2020";
state.BaseRequest.VideoRangeType = "DOVIWithEL";
var helper = CreateHelper(true);
Assert.False(helper.IsDoviRemoved(state));
Assert.Equal("-bsf:v hevc_mp4toannexb", helper.GetBitStreamArgs(state, MediaStreamType.Video));
}
private static EncodingJobInfo CreateState(string codec, string? transfer)
{
var stream = new MediaStream
{
Type = MediaStreamType.Video,
Codec = codec,
Width = 1920,
Height = 1080,
BitDepth = 10,
DvProfile = codec == "hevc" ? 7 : 10,
DvBlSignalCompatibilityId = codec == "hevc" ? 6 : 1,
RpuPresentFlag = 1,
BlPresentFlag = 1,
ColorSpace = "bt709",
ColorPrimaries = "bt709",
ColorTransfer = transfer
};
return new EncodingJobInfo(TranscodingJobType.Hls)
{
VideoStream = stream,
MediaSource = new MediaSourceInfo { Container = "mkv", MediaStreams = [stream] },
BaseRequest = new VideoRequestDto(),
OutputVideoCodec = "copy",
IsVideoRequest = true,
IsInputVideo = true
};
}
private static EncodingHelper CreateHelper(bool supportsRemoval)
{
var encoder = new Mock<IMediaEncoder>();
encoder.Setup(x => x.SupportsBitStreamFilterWithOption(It.IsAny<BitStreamFilterOptionType>())).Returns(supportsRemoval);
encoder.Setup(x => x.SupportsFilter("tonemapx")).Returns(true);
encoder.SetupGet(x => x.EncoderVersion).Returns(new Version(8, 1));
return new EncodingHelper(
Mock.Of<IApplicationPaths>(),
encoder.Object,
Mock.Of<ISubtitleEncoder>(),
Mock.Of<IConfiguration>(),
Mock.Of<IConfigurationManager>(),
Mock.Of<IPathManager>());
}
}
@@ -0,0 +1,40 @@
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.MediaEncoding;
using Moq;
using Xunit;
using IConfiguration = Microsoft.Extensions.Configuration.IConfiguration;
namespace Jellyfin.Controller.Tests.MediaEncoding;
public class EncodingHelperInferAudioCodecTests
{
[Theory]
// Manifests and other containers that carry no inferable audio codec.
[InlineData("m3u8", "aac")]
[InlineData("mpd", "aac")]
[InlineData("wtv", "aac")]
[InlineData("", "aac")]
// Containers with a well known audio codec.
[InlineData("mp4", "aac")]
[InlineData("mkv", "aac")]
[InlineData("webm", "opus")]
[InlineData("ts", "mp3")]
// Containers named after the codec they carry.
[InlineData("flac", "flac")]
[InlineData("opus", "opus")]
[InlineData("ac3", "ac3")]
public void InferAudioCodec_ReturnsAnAudioCodec(string container, string expected)
{
Assert.Equal(expected, Create().InferAudioCodec(container));
}
private static EncodingHelper Create()
=> new(
Mock.Of<IApplicationPaths>(),
Mock.Of<IMediaEncoder>(),
Mock.Of<ISubtitleEncoder>(),
Mock.Of<IConfiguration>(),
Mock.Of<IConfigurationManager>(),
Mock.Of<IPathManager>());
}
@@ -0,0 +1,73 @@
using SkiaSharp;
using Xunit;
namespace Jellyfin.Drawing.Skia.Tests;
public class SkiaEncoderSharpenTests
{
private static SKBitmap CreateBitmap(int width, int height, SKColor fill)
{
var bitmap = new SKBitmap(new SKImageInfo(width, height, SKColorType.Rgba8888, SKAlphaType.Premul));
using var canvas = new SKCanvas(bitmap);
canvas.Clear(fill);
return bitmap;
}
[Fact]
public void SharpenInPlace_UniformImage_IsUnchanged()
{
// 1.4 * v - 4 * 0.1 * v = v for any uniform value.
using var bitmap = CreateBitmap(8, 8, new SKColor(100, 150, 200));
SkiaEncoder.SharpenInPlace(bitmap);
for (var y = 0; y < bitmap.Height; y++)
{
for (var x = 0; x < bitmap.Width; x++)
{
Assert.Equal(new SKColor(100, 150, 200), bitmap.GetPixel(x, y));
}
}
}
[Fact]
public void SharpenInPlace_BrightPixelOnDarkBackground_SharpensEdge()
{
using var bitmap = CreateBitmap(5, 5, new SKColor(50, 50, 50));
bitmap.SetPixel(2, 2, new SKColor(250, 250, 250, 255));
SkiaEncoder.SharpenInPlace(bitmap);
// Center: 1.4 * 250 - 0.1 * 4 * 50 = 330 -> clamped to 255.
Assert.Equal(new SKColor(255, 255, 255), bitmap.GetPixel(2, 2));
// Direct neighbor: 1.4 * 50 - 0.1 * (250 + 3 * 50) = 30.
Assert.Equal(new SKColor(30, 30, 30), bitmap.GetPixel(1, 2));
// Far corner is only surrounded by background: unchanged.
Assert.Equal(new SKColor(50, 50, 50), bitmap.GetPixel(0, 0));
}
[Fact]
public void SharpenInPlace_EdgePixels_ClampOutOfBoundsTaps()
{
// A corner pixel reuses itself for the two out-of-bounds taps:
// 1.4 * v - 0.1 * (2 * v + right + down).
using var bitmap = CreateBitmap(3, 3, new SKColor(100, 100, 100));
bitmap.SetPixel(0, 0, new SKColor(200, 200, 200, 255));
SkiaEncoder.SharpenInPlace(bitmap);
// 1.4 * 200 - 0.1 * (200 + 200 + 100 + 100) = 220.
Assert.Equal(new SKColor(220, 220, 220), bitmap.GetPixel(0, 0));
}
[Fact]
public void SharpenInPlace_UnsupportedColorType_IsLeftUntouched()
{
using var bitmap = new SKBitmap(new SKImageInfo(4, 4, SKColorType.Gray8, SKAlphaType.Opaque));
bitmap.Erase(new SKColor(80, 80, 80));
SkiaEncoder.SharpenInPlace(bitmap);
Assert.Equal(80, bitmap.GetPixel(1, 1).Red);
}
}
@@ -0,0 +1,72 @@
using System.Collections.Generic;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.LiveTv.TunerHosts;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.LiveTv;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Jellyfin.LiveTv.Tests
{
public class M3UTunerHostTests
{
[Theory]
// A manifest is not a byte stream, so it must never be offered for direct play.
[InlineData("http://example.com/live/1234.m3u8", false)]
[InlineData("http://example.com/live/1234.m3u8?token=abc", false)]
[InlineData("http://example.com/live/1234.mpd", false)]
// Byte streams are unaffected.
[InlineData("http://example.com/live/1234.ts", true)]
[InlineData("http://example.com/live/1234", true)]
public async Task GetChannelStreamMediaSources_ManifestPath_DisablesDirectPlay(string path, bool expectDirectPlay)
{
var mediaSourceManager = new Mock<IMediaSourceManager>();
mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.Http);
var host = new TestableM3UTunerHost(
Mock.Of<IServerConfigurationManager>(),
mediaSourceManager.Object,
Mock.Of<ILogger<M3UTunerHost>>(),
Mock.Of<IFileSystem>(),
Mock.Of<IHttpClientFactory>(),
Mock.Of<IServerApplicationHost>(),
Mock.Of<INetworkManager>(),
Mock.Of<IStreamHelper>());
var sources = await host.GetMediaSources(
new TunerHostInfo { TunerCount = 0, EnableStreamLooping = false },
new ChannelInfo { Path = path });
Assert.Equal(expectDirectPlay, sources[0].SupportsDirectPlay);
}
private sealed class TestableM3UTunerHost : M3UTunerHost
{
public TestableM3UTunerHost(
IServerConfigurationManager config,
IMediaSourceManager mediaSourceManager,
ILogger<M3UTunerHost> logger,
IFileSystem fileSystem,
IHttpClientFactory httpClientFactory,
IServerApplicationHost appHost,
INetworkManager networkManager,
IStreamHelper streamHelper)
: base(config, mediaSourceManager, logger, fileSystem, httpClientFactory, appHost, networkManager, streamHelper)
{
}
public Task<List<MediaSourceInfo>> GetMediaSources(TunerHostInfo tuner, ChannelInfo channel)
=> GetChannelStreamMediaSources(tuner, channel, CancellationToken.None);
}
}
}
@@ -0,0 +1,103 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.MediaEncoding.Encoder;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.MediaInfo;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
namespace Jellyfin.MediaEncoding.Tests.Encoder;
public class ProcessWrapperTests
{
[Fact]
public async Task ExitedProcess_StaysUsableForTheCallerThatStartedIt()
{
using var process = CreateProcess();
using var exitHandled = new ManualResetEventSlim(false);
using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder()))
{
// Subscribed after the wrapper, so by the time this is set the wrapper's own handler has
// already run: whatever it does to the process has happened.
process.Exited += (_, _) => exitHandled.Set();
process.Start();
await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
Assert.True(exitHandled.Wait(TimeSpan.FromSeconds(15), TestContext.Current.CancellationToken), "The process never raised Exited.");
// The caller still owns the process here. Disposing it from the exit handler handed
// whoever exited quickest an ObjectDisposedException out of these three lines.
var output = await process.StandardOutput.ReadToEndAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
Assert.Equal("jellyfin", output.Trim());
Assert.True(wrapper.HasExited);
Assert.Equal(3, wrapper.ExitCode);
}
}
[Fact]
public async Task ExitState_IsReadableBeforeTheExitEventArrives()
{
using var process = CreateProcess();
using (var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder()))
{
process.Start();
await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
// The exit event is raised on the thread pool and can lag behind the wait that just
// returned, so neither of these may depend on it having arrived.
Assert.True(wrapper.HasExited);
Assert.Equal(3, wrapper.ExitCode);
}
}
[Fact]
public async Task ExitCode_SurvivesDisposal()
{
using var process = CreateProcess();
var wrapper = new MediaEncoder.ProcessWrapper(process, CreateEncoder());
process.Start();
await process.WaitForExitAsync(TestContext.Current.CancellationToken).ConfigureAwait(true);
var exitCode = wrapper.ExitCode;
wrapper.Dispose();
Assert.Equal(exitCode, wrapper.ExitCode);
Assert.True(wrapper.HasExited);
}
private static MediaEncoder CreateEncoder()
=> new(
Mock.Of<ILogger<MediaEncoder>>(),
Mock.Of<IServerConfigurationManager>(),
Mock.Of<IFileSystem>(),
Mock.Of<IBlurayExaminer>(),
Mock.Of<ILocalizationManager>(),
new ConfigurationBuilder().Build(),
Mock.Of<IServerConfigurationManager>());
// Writes to stdout and exits immediately with a non-zero code, standing in for the ffprobe that
// rejects a file outright - the process that used to win the race against its own caller.
private static Process CreateProcess()
{
var startInfo = OperatingSystem.IsWindows()
? new ProcessStartInfo("cmd.exe", "/c echo jellyfin & exit 3")
: new ProcessStartInfo("/bin/sh", "-c \"printf 'jellyfin\\n'; exit 3\"");
startInfo.CreateNoWindow = true;
startInfo.UseShellExecute = false;
startInfo.RedirectStandardOutput = true;
return new Process { StartInfo = startInfo, EnableRaisingEvents = true };
}
}
@@ -0,0 +1,104 @@
using System;
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using MediaBrowser.Model.Session;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Model.Tests.Dlna;
public class StreamBuilderManifestContainerTests
{
[Theory]
// A manifest describes a stream instead of carrying one, so it can never be direct played,
// even when the client claims to support the container.
[InlineData("hls")]
[InlineData("hls,applehttp")]
[InlineData("applehttp")]
[InlineData("dash")]
public void GetOptimalVideoStream_ManifestContainer_DoesNotDirectPlay(string container)
{
var streamInfo = BuildFor(container);
Assert.NotNull(streamInfo);
Assert.Equal(PlayMethod.Transcode, streamInfo.PlayMethod);
}
[Fact]
public void GetOptimalVideoStream_ByteStreamContainer_StillDirectPlays()
{
var streamInfo = BuildFor("mp4");
Assert.NotNull(streamInfo);
Assert.Equal(PlayMethod.DirectPlay, streamInfo.PlayMethod);
}
private static StreamInfo? BuildFor(string container)
{
var mediaSource = new MediaSourceInfo
{
Id = "test-source",
Path = "http://example.com/live/channel",
Protocol = MediaProtocol.Http,
Container = container,
SupportsDirectPlay = true,
SupportsDirectStream = true,
SupportsTranscoding = true,
IsInfiniteStream = true,
IsRemote = true,
MediaStreams =
[
new MediaStream { Type = MediaStreamType.Video, Index = 0, Codec = "h264" },
new MediaStream { Type = MediaStreamType.Audio, Index = 1, Codec = "aac" }
]
};
var profile = new DeviceProfile
{
Name = "Manifest aware client",
DirectPlayProfiles =
[
new DirectPlayProfile
{
Type = DlnaProfileType.Video,
Container = "mp4,hls,applehttp,dash",
VideoCodec = "h264",
AudioCodec = "aac"
}
],
TranscodingProfiles =
[
new TranscodingProfile
{
Type = DlnaProfileType.Video,
Context = EncodingContext.Streaming,
Protocol = MediaStreamProtocol.hls,
Container = "ts",
VideoCodec = "h264",
AudioCodec = "aac"
}
]
};
var options = new MediaOptions
{
ItemId = new Guid("11D229B7-2D48-4B95-9F9B-49F6AB75E613"),
MediaSourceId = mediaSource.Id,
MediaSources = [mediaSource],
DeviceId = "test-deviceId",
Profile = profile,
AllowAudioStreamCopy = true,
AllowVideoStreamCopy = true,
EnableDirectStream = false // This is disabled in server
};
var transcodeSupport = new Mock<ITranscoderSupport>();
return new StreamBuilder(transcodeSupport.Object, new NullLogger<StreamBuilderManifestContainerTests>())
.GetOptimalVideoStream(options);
}
}
@@ -0,0 +1,129 @@
using Jellyfin.Data.Enums;
using MediaBrowser.Model.Entities;
using Xunit;
namespace Jellyfin.Model.Tests.Entities;
public class MediaStreamVideoRangeTests
{
[Theory]
[InlineData(7, 6, "smpte2084", false, VideoRangeType.DOVIWithEL)]
[InlineData(7, 6, "smpte2084", true, VideoRangeType.DOVIWithELHDR10Plus)]
[InlineData(8, 1, "smpte2084", false, VideoRangeType.DOVIWithHDR10)]
[InlineData(8, 1, "smpte2084", true, VideoRangeType.DOVIWithHDR10Plus)]
[InlineData(8, 4, "arib-std-b67", false, VideoRangeType.DOVIWithHLG)]
[InlineData(10, 1, "smpte2084", false, VideoRangeType.DOVIWithHDR10)]
[InlineData(10, 1, "smpte2084", true, VideoRangeType.DOVIWithHDR10Plus)]
[InlineData(10, 4, "arib-std-b67", false, VideoRangeType.DOVIWithHLG)]
[InlineData(8, 1, "SMPTE2084", false, VideoRangeType.DOVIWithHDR10)]
[InlineData(8, 4, "ARIB-STD-B67", false, VideoRangeType.DOVIWithHLG)]
public void GetVideoColorRange_ValidDovi_PreservesRangeType(
int profile, int compatibilityId, string transfer, bool hdr10Plus, VideoRangeType expected)
{
var stream = CreateDovi(profile, compatibilityId, "BT2020NC", transfer, "BT2020", hdr10Plus);
Assert.Equal((VideoRange.HDR, expected), stream.GetVideoColorRange());
}
[Theory]
[InlineData("bt709", "bt709", "bt709", VideoRange.SDR)]
[InlineData("bt2020nc", "bt709", "bt2020", VideoRange.SDR)]
[InlineData("bt2020nc", null, "bt2020", VideoRange.SDR)]
[InlineData("bt2020nc", "", "bt2020", VideoRange.SDR)]
[InlineData("bt2020nc", "unknown", "bt2020", VideoRange.SDR)]
[InlineData("bt2020nc", "bt2020-10", "bt2020", VideoRange.SDR)]
[InlineData(null, null, null, VideoRange.SDR)]
[InlineData("bt709", "smpte2084", "bt2020", VideoRange.HDR)]
[InlineData("bt2020nc", "smpte2084", "bt709", VideoRange.HDR)]
[InlineData(null, "smpte2084", "bt2020", VideoRange.HDR)]
[InlineData("bt2020nc", "smpte2084", null, VideoRange.HDR)]
[InlineData("bt709", "arib-std-b67", "bt2020", VideoRange.HDR)]
[InlineData("bt2020nc", "arib-std-b67", "bt709", VideoRange.HDR)]
[InlineData(null, "arib-std-b67", "bt2020", VideoRange.HDR)]
[InlineData("bt2020nc", "arib-std-b67", null, VideoRange.HDR)]
public void GetVideoColorRange_InvalidDoviColors_UsesBaseLayerRange(
string? space, string? transfer, string? primaries, VideoRange expected)
{
// Cover every HDR-compatible DV profile, including the HDR10+ variants.
foreach (var (profile, compatibilityId) in new[] { (7, 6), (8, 1), (8, 4), (10, 1), (10, 4) })
{
foreach (var hdr10Plus in new[] { false, true })
{
var stream = CreateDovi(profile, compatibilityId, space, transfer, primaries, hdr10Plus);
Assert.Equal(expected, stream.VideoRange);
Assert.Equal(VideoRangeType.DOVIInvalid, stream.VideoRangeType);
}
}
}
[Theory]
[InlineData(7, 6, "arib-std-b67")]
[InlineData(8, 1, "arib-std-b67")]
[InlineData(8, 4, "smpte2084")]
[InlineData(10, 1, "arib-std-b67")]
[InlineData(10, 4, "smpte2084")]
public void GetVideoColorRange_WrongHdrTransfer_InvalidButStillHdr(int profile, int compatibilityId, string transfer)
{
var stream = CreateDovi(profile, compatibilityId, "bt2020nc", transfer, "bt2020", true);
Assert.Equal((VideoRange.HDR, VideoRangeType.DOVIInvalid), stream.GetVideoColorRange());
}
[Theory]
[InlineData(5, 0, null, VideoRange.HDR, VideoRangeType.DOVI)]
[InlineData(10, 0, null, VideoRange.HDR, VideoRangeType.DOVI)]
[InlineData(8, 2, "bt709", VideoRange.SDR, VideoRangeType.DOVIWithSDR)]
[InlineData(10, 2, "bt709", VideoRange.SDR, VideoRangeType.DOVIWithSDR)]
public void GetVideoColorRange_OtherDoviProfiles_PreservesClassification(
int profile, int compatibilityId, string? transfer, VideoRange range, VideoRangeType rangeType)
{
var stream = CreateDovi(profile, compatibilityId, "bt709", transfer, "bt709", false);
Assert.Equal((range, rangeType), stream.GetVideoColorRange());
}
[Theory]
[InlineData(8, null, VideoRange.SDR)]
[InlineData(8, "bt709", VideoRange.SDR)]
[InlineData(8, "smpte2084", VideoRange.HDR)]
[InlineData(10, null, VideoRange.SDR)]
[InlineData(10, "arib-std-b67", VideoRange.HDR)]
public void GetVideoColorRange_InvalidCompatibilityId_UsesBaseLayerRange(int profile, string? transfer, VideoRange expected)
{
var stream = CreateDovi(profile, 6, "bt2020nc", transfer, "bt2020", false);
Assert.Equal((expected, VideoRangeType.DOVIInvalid), stream.GetVideoColorRange());
}
[Theory]
[InlineData("bt709", false, VideoRange.SDR, VideoRangeType.SDR)]
[InlineData(null, false, VideoRange.SDR, VideoRangeType.SDR)]
[InlineData("smpte2084", false, VideoRange.HDR, VideoRangeType.HDR10)]
[InlineData("smpte2084", true, VideoRange.HDR, VideoRangeType.HDR10Plus)]
[InlineData("arib-std-b67", false, VideoRange.HDR, VideoRangeType.HLG)]
public void GetVideoColorRange_WithoutDovi_PreservesClassification(
string? transfer, bool hdr10Plus, VideoRange range, VideoRangeType rangeType)
{
var stream = new MediaStream { Type = MediaStreamType.Video, ColorTransfer = transfer, Hdr10PlusPresentFlag = hdr10Plus };
Assert.Equal((range, rangeType), stream.GetVideoColorRange());
stream.Type = MediaStreamType.Audio;
Assert.Equal((VideoRange.Unknown, VideoRangeType.Unknown), stream.GetVideoColorRange());
}
private static MediaStream CreateDovi(int profile, int compatibilityId, string? space, string? transfer, string? primaries, bool hdr10Plus)
=> new()
{
Type = MediaStreamType.Video,
DvProfile = profile,
DvBlSignalCompatibilityId = compatibilityId,
RpuPresentFlag = 1,
BlPresentFlag = 1,
ElPresentFlag = profile == 7 ? 1 : 0,
ColorSpace = space,
ColorTransfer = transfer,
ColorPrimaries = primaries,
Hdr10PlusPresentFlag = hdr10Plus
};
}
@@ -74,6 +74,9 @@ namespace Jellyfin.Naming.Tests.TV
[InlineData("Season 5/S05E23 11-59 [HDTV-1080p][x265 AC3].mkv", null)]
[InlineData("Season 5/S05E23 11-59 [HDTV-1080p][HEVC AC3].mkv", null)]
[InlineData("Season 1/S01E01 1-23-45 [Bluray-1080p][AV1 Opus].mkv", null)]
// Episode markers in the episode title must not be read as an episode range
[InlineData("Season 03/Star Trek Enterprise (2001) - S03E21 - E2 (1080p BluRay x265).mkv", null)]
[InlineData("Season 02/Series Name (2001) - S02E10 - E5 [WEBRip-1080p].mkv", null)]
public void TestGetEndingEpisodeNumberFromFile(string filename, int? endingEpisodeNumber)
{
var result = _episodePathParser.Parse(filename, false);
@@ -1,4 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
@@ -377,6 +378,116 @@ namespace Jellyfin.Providers.Tests.Manager
GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, ownedItem: true);
}
[Fact]
public async Task QueueRefresh_ManyItemsQueuedFromManyThreads_ProcessesEveryOne()
{
const int ItemCount = 2000;
var queued = Enumerable.Range(0, ItemCount).Select(_ => Guid.NewGuid()).ToArray();
var processed = new ConcurrentBag<Guid>();
var allProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var libraryManager = new Mock<ILibraryManager>();
libraryManager.Setup(i => i.GetItemById(It.IsAny<Guid>()))
.Returns((Guid id) =>
{
// Returning null drains the entry without the whole refresh machinery.
processed.Add(id);
if (processed.Count == ItemCount)
{
allProcessed.TrySetResult();
}
return null;
});
using var providerManager = GetProviderManager(libraryManager: libraryManager.Object);
await Parallel.ForEachAsync(
queued,
TestContext.Current.CancellationToken,
(id, _) =>
{
providerManager.QueueRefresh(id, new MetadataRefreshOptions(Mock.Of<IDirectoryService>()), RefreshPriority.Normal);
return ValueTask.CompletedTask;
});
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
try
{
await allProcessed.Task.WaitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
// Fall through so the assertions report what was lost.
}
Assert.Empty(providerManager.GetRefreshQueue());
Assert.Equal(queued.Order().ToArray(), processed.Order().ToArray());
}
[Fact]
public async Task QueueRefresh_RefreshCancelsForItsOwnReasons_KeepsDrainingTheQueue()
{
// A provider timeout arrives as an OperationCanceledException, indistinguishable from
// a shutdown; treating it as one would strand the rest of the queue.
const int ItemCount = 200;
var queued = Enumerable.Range(0, ItemCount).Select(_ => Guid.NewGuid()).ToArray();
var processed = new ConcurrentBag<Guid>();
var allProcessed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
using var allQueued = new ManualResetEventSlim(false);
var cancelledOnce = false;
var libraryManager = new Mock<ILibraryManager>();
libraryManager.Setup(i => i.GetItemById(It.IsAny<Guid>()))
.Returns((Guid id) =>
{
if (!cancelledOnce)
{
cancelledOnce = true;
// Hold the first entry until the whole batch is queued.
allQueued.Wait(TimeSpan.FromSeconds(30));
throw new OperationCanceledException("provider timed out");
}
processed.Add(id);
if (processed.Count == ItemCount - 1)
{
allProcessed.TrySetResult();
}
return null;
});
using var providerManager = GetProviderManager(libraryManager: libraryManager.Object);
foreach (var id in queued)
{
providerManager.QueueRefresh(id, new MetadataRefreshOptions(Mock.Of<IDirectoryService>()), RefreshPriority.Normal);
}
allQueued.Set();
using var timeout = CancellationTokenSource.CreateLinkedTokenSource(TestContext.Current.CancellationToken);
timeout.CancelAfter(TimeSpan.FromSeconds(30));
try
{
await allProcessed.Task.WaitAsync(timeout.Token);
}
catch (OperationCanceledException)
{
// Fall through so the assertions report what was stranded.
}
Assert.Empty(providerManager.GetRefreshQueue());
Assert.Equal(ItemCount - 1, processed.Count);
}
private static void GetMetadataProviders_CanRefreshMetadata_Tester(
string providerType,
bool expected,
@@ -554,15 +665,20 @@ namespace Jellyfin.Providers.Tests.Manager
private static ProviderManager GetProviderManager(
ServerConfiguration? serverConfiguration = null,
LibraryOptions? libraryOptions = null,
IBaseItemManager? baseItemManager = null)
IBaseItemManager? baseItemManager = null,
ILibraryManager? libraryManager = null)
{
var serverConfigurationManager = new Mock<IServerConfigurationManager>(MockBehavior.Strict);
serverConfigurationManager.Setup(i => i.Configuration)
.Returns(serverConfiguration ?? new ServerConfiguration());
var libraryManager = new Mock<ILibraryManager>(MockBehavior.Strict);
libraryManager.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>()))
.Returns(libraryOptions ?? new LibraryOptions());
if (libraryManager is null)
{
var libraryManagerMock = new Mock<ILibraryManager>(MockBehavior.Strict);
libraryManagerMock.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>()))
.Returns(libraryOptions ?? new LibraryOptions());
libraryManager = libraryManagerMock.Object;
}
var providerManager = new ProviderManager(
Mock.Of<IHttpClientFactory>(),
@@ -572,7 +688,7 @@ namespace Jellyfin.Providers.Tests.Manager
_logger,
Mock.Of<IFileSystem>(),
Mock.Of<IServerApplicationPaths>(),
libraryManager.Object,
libraryManager,
baseItemManager!,
Mock.Of<ILyricManager>(),
Mock.Of<IMemoryCache>(),
@@ -179,6 +179,146 @@ public class MediaInfoResolverTests
Assert.Empty(streams);
}
[Fact]
public void GetExternalFiles_VobSubIdxAndSubPair_OnlyReturnsIdxFile()
{
// VobSub (.sub) payloads only carry per-track language metadata when read
// alongside their paired .idx index file. When both are present, only the
// .idx file should be returned so it (not the raw .sub) gets probed.
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
var video = new Movie
{
Path = VideoDirectoryPath + "/My.Video.mkv"
};
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.sub" });
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
var stream = Assert.Single(streams);
Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GetExternalFiles_VobSubIdxWithoutMatchingSub_DoesNotReturnIdxFile()
{
// An .idx file with no paired .sub cannot be probed for anything, so it must be
// left out entirely rather than surfaced as a doomed-to-fail probe candidate.
// Surfacing it anyway would also make it "exist" from Jellyfin's perspective
// even after the real .sub is deleted, preventing stale subtitle stream data
// from ever being cleared on a rescan.
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
var video = new Movie
{
Path = VideoDirectoryPath + "/My.Video.mkv"
};
var directoryService = GetDirectoryServiceForExternalFile("My.Video.idx");
var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList();
Assert.Empty(streams);
}
[Fact]
public void GetExternalFiles_StandaloneSubWithoutIdx_StillReturnsSubFile()
{
// Guards against the .idx/.sub pairing suppression firing when there is no
// .idx sidecar at all - a lone .sub file must still be returned.
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
var video = new Movie
{
Path = VideoDirectoryPath + "/My.Video.mkv"
};
var directoryService = GetDirectoryServiceForExternalFile("My.Video.sub");
var streams = _subtitleResolver.GetExternalFiles(video, directoryService, false).ToList();
var stream = Assert.Single(streams);
Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GetExternalFiles_VobSubIdxAndSubInDifferentDirectories_DoesNotPair()
{
// A same-named .idx and .sub split across the video folder and the internal
// metadata folder cannot be paired by ffprobe (it only looks next to the .idx),
// so the .sub must still be returned, but the orphaned .idx (no sibling .sub in
// its own directory) must be left out since it cannot be probed.
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
var video = new Movie
{
Path = VideoDirectoryPath + "/My.Video.mkv"
};
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { VideoDirectoryPath + "/My.Video.sub" });
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { MetadataDirectoryPath + "/My.Video.idx" });
var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
var stream = Assert.Single(streams);
Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GetExternalFiles_VobSubIdxAndSubWithMatchingLanguageFlag_SuppressesSub()
{
// A .idx/.sub pair sharing the same filename flags (e.g. a language token) should
// still pair and suppress the .sub, just like an unflagged pair.
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
var video = new Movie
{
Path = VideoDirectoryPath + "/My.Video.mkv"
};
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { VideoDirectoryPath + "/My.Video.en.idx", VideoDirectoryPath + "/My.Video.en.sub" });
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
var stream = Assert.Single(streams);
Assert.EndsWith(".idx", stream.Path, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public void GetExternalFiles_VobSubIdxAndSubWithMismatchedNames_DoesNotPair()
{
// An .idx and .sub with different basenames (e.g. differing filename flags) are not
// a pair ffprobe would resolve. The .sub must still be returned, but the orphaned
// .idx (no same-named sibling .sub) must be left out since it cannot be probed.
BaseItem.MediaSourceManager = Mock.Of<IMediaSourceManager>();
var video = new Movie
{
Path = VideoDirectoryPath + "/My.Video.mkv"
};
var directoryService = new Mock<IDirectoryService>(MockBehavior.Strict);
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(VideoDirectoryRegex), It.IsAny<bool>()))
.Returns(new[] { VideoDirectoryPath + "/My.Video.idx", VideoDirectoryPath + "/My.Video.en.sub" });
directoryService.Setup(ds => ds.GetFilePaths(It.IsRegex(MetadataDirectoryRegex), It.IsAny<bool>()))
.Returns(Array.Empty<string>());
var streams = _subtitleResolver.GetExternalFiles(video, directoryService.Object, false).ToList();
var stream = Assert.Single(streams);
Assert.EndsWith(".sub", stream.Path, StringComparison.OrdinalIgnoreCase);
}
[Theory]
[InlineData("https://url.com/My.Video.mkv")]
[InlineData(VideoDirectoryPath)] // valid but no files found for this test
@@ -1,5 +1,6 @@
using System;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library;
@@ -15,9 +16,23 @@ using Xunit;
namespace Jellyfin.Providers.Tests.TV;
public class EpisodeMetadataServiceTests
// put tests that mock the static LibraryManager in the same collection to avoid test interference
[Collection("LibraryManagerTests")]
public sealed class EpisodeMetadataServiceTests : IDisposable
{
private readonly TestEpisodeMetadataService _service = new();
private readonly ILibraryManager? _previousLibraryManager;
public EpisodeMetadataServiceTests()
{
_previousLibraryManager = BaseItem.LibraryManager;
BaseItem.LibraryManager = Mock.Of<ILibraryManager>();
}
public void Dispose()
{
BaseItem.LibraryManager = _previousLibraryManager;
}
[Fact]
public void MergeData_ProviderSeasonOverridesPathDerivedSeason()
@@ -88,6 +103,59 @@ public class EpisodeMetadataServiceTests
Assert.Equal(1, target.Item.ParentIndexNumber);
}
[Theory]
[InlineData(2, 1)]
[InlineData(22, 21)]
[InlineData(21, 2)] // e.g. "Series - S03E21 - E2 (1080p BluRay x265).mkv", where "E2" is the episode title
public void BeforeSave_ReversedEpisodeRange_ClearsIndexNumberEnd(int indexNumber, int indexNumberEnd)
{
var item = new Episode
{
IndexNumber = indexNumber,
IndexNumberEnd = indexNumberEnd
};
var updateType = _service.BeforeSave(item);
// The episode number identifies the item, so it is kept and the impossible range is dropped
Assert.Equal(indexNumber, item.IndexNumber);
Assert.Null(item.IndexNumberEnd);
Assert.True(updateType.HasFlag(ItemUpdateType.MetadataImport));
}
[Fact]
public void BeforeSave_EpisodeRangeWithoutStart_ClearsIndexNumberEnd()
{
var item = new Episode
{
IndexNumber = null,
IndexNumberEnd = 2
};
var updateType = _service.BeforeSave(item);
Assert.Null(item.IndexNumberEnd);
Assert.Null(item.IndexNumber);
Assert.True(updateType.HasFlag(ItemUpdateType.MetadataImport));
}
[Theory]
[InlineData(1, 2)] // Regular multi episode file
[InlineData(1, 1)] // Degenerate but not contradictory
public void BeforeSave_ValidEpisodeRange_KeepsIndexNumberEnd(int indexNumber, int indexNumberEnd)
{
var item = new Episode
{
IndexNumber = indexNumber,
IndexNumberEnd = indexNumberEnd
};
_service.BeforeSave(item);
Assert.Equal(indexNumber, item.IndexNumber);
Assert.Equal(indexNumberEnd, item.IndexNumberEnd);
}
private sealed class TestEpisodeMetadataService : EpisodeMetadataService
{
public TestEpisodeMetadataService()
@@ -106,5 +174,10 @@ public class EpisodeMetadataServiceTests
{
MergeData(source, target, Array.Empty<MetadataField>(), replaceData, mergeMetadataSettings);
}
public ItemUpdateType BeforeSave(Episode item)
{
return BeforeSaveInternal(item, false, ItemUpdateType.None);
}
}
}
@@ -1,6 +1,9 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Model.Entities;
using MediaBrowser.Providers.Plugins.Tmdb;
using TMDbLib.Objects.Search;
using Xunit;
namespace Jellyfin.Providers.Tests.Tmdb
@@ -71,5 +74,169 @@ namespace Jellyfin.Providers.Tests.Tmdb
Assert.False(new Movie().TryGetTmdbId(out var tmdbId));
Assert.Equal(0, tmdbId);
}
[Theory]
[InlineData("The Amityville Horror", "The Amityville Horror")]
[InlineData("WALL-E", "WALL E")]
// The interpunct is kept, it matches the TMDb title better than a space does.
[InlineData("WALL·E", "WALL·E")]
[InlineData("50-50", "50 50")]
[InlineData("A Christmas No. 1", "A Christmas No 1")]
// Vulgar fractions are numbers, dropping them turned "8½" into a search for "8".
[InlineData("8½", "8½")]
[InlineData("9½ Weeks", "9½ Weeks")]
[InlineData(" Léon: The Professional ", "Léon The Professional")]
public static void CleanName_Valid_Success(string name, string expected)
{
Assert.Equal(expected, TmdbUtils.CleanName(name));
}
[Theory]
[InlineData("WALL-E", "wall e")]
[InlineData("WALL·E", "wall e")]
[InlineData("WALL E", "wall e")]
[InlineData("8½", "8½")]
[InlineData("Ocean's Eleven", "ocean s eleven")]
[InlineData(null, "")]
[InlineData(" ", "")]
public static void NormalizeTitle_Valid_Success(string? title, string expected)
{
Assert.Equal(expected, TmdbUtils.NormalizeTitle(title));
}
[Theory]
[MemberData(nameof(FindBestMatch_Movies_TestData))]
public static void FindBestMatch_Movies_PicksExpected(string description, string name, int year, IReadOnlyList<SearchMovie> results, int expectedId)
{
var match = TmdbUtils.FindBestMatch(results, name, year);
Assert.NotNull(match);
Assert.True(expectedId == match.Id, $"{description}: expected {expectedId} but matched {match.Id}");
}
[Fact]
public static void FindBestMatch_Series_PicksMatchingFirstAirYear()
{
IReadOnlyList<SearchTv> results =
[
Series(10042, "Doc", "Doc", 2001),
Series(101048, "Doc", "Doc", 2020),
Series(255055, "Doc", "Doc", 2025),
Series(2430, "Doc Martin", "Doc Martin", 2004)
];
var match = TmdbUtils.FindBestMatch(results, "Doc", 2025);
Assert.NotNull(match);
Assert.Equal(255055, match.Id);
}
[Fact]
public static void FindBestMatch_NoResults_ReturnsNull()
{
Assert.Null(TmdbUtils.FindBestMatch(Array.Empty<SearchMovie>(), "Mulan", 2020));
Assert.Null(TmdbUtils.FindBestMatch(Array.Empty<SearchTv>(), "Doc", 2025));
Assert.Null(TmdbUtils.FindBestMatch((IReadOnlyList<SearchMovie>?)null, "Mulan", 2020));
Assert.Null(TmdbUtils.FindBestMatch((IReadOnlyList<SearchTv>?)null, "Doc", 2025));
}
public static TheoryData<string, string, int, IReadOnlyList<SearchMovie>, int> FindBestMatch_Movies_TestData()
=> new()
{
// TMDb's year parameter does not filter, so the remake and the original both come back and
// the wrong one is first. Results are in the order the live API returned them.
{
"Mulan (2020)", "Mulan", 2020,
[Movie(10674, "Mulan", "Mulan", 1998), Movie(337401, "Mulan", "Mulan", 2020), Movie(752662, "Hua Mulan", "花木兰", 2020)],
337401
},
{
"Mulan (1998)", "Mulan", 1998,
[Movie(10674, "Mulan", "Mulan", 1998), Movie(337401, "Mulan", "Mulan", 2020), Movie(752662, "Hua Mulan", "花木兰", 2020)],
10674
},
{
"Aladdin (2019)", "Aladdin", 2019,
[Movie(812, "Aladdin", "Aladdin", 1992), Movie(420817, "Aladdin", "Aladdin", 2019), Movie(602411, "Adventures of Aladdin", "Adventures of Aladdin", 2019)],
420817
},
{
"The Lion King (2019)", "The Lion King", 2019,
[Movie(8587, "The Lion King", "The Lion King", 1994), Movie(420818, "The Lion King", "The Lion King", 2019)],
420818
},
{
"The Amityville Horror (1979)", "The Amityville Horror", 1979,
[Movie(10065, "The Amityville Horror", "The Amityville Horror", 2005), Movie(11449, "The Amityville Horror", "The Amityville Horror", 1979)],
11449
},
// A featurette outranks the film it belongs to. The interpunct must not stop "WALL-E" from
// matching "WALL·E", or the prefix match on the featurette wins.
{
"WALL-E (2008)", "WALL-E", 2008,
[Movie(877268, "WALL·E's Treasures & Trinkets", "WALL·E's Treasures & Trinkets", 2008), Movie(10681, "WALL·E", "WALL·E", 2008), Movie(10673, "Wall Street", "Wall Street", 1987)],
10681
},
// The name only survives as "8" if the fraction is stripped, and then every 1963 result ties.
{
"8½ (1963)", "8½", 1963,
[Movie(422801, "Interpol Code 8", "国際秘密警察 指令第8号", 1963), Movie(520251, "Um 8 Uhr kommt Sadowski", "Um 8 Uhr kommt Sadowski", 1963), Movie(422, "8½", "8½", 1963)],
422
},
// Matched on the original title, the localized one is unrecognizable.
{
"Ściany mają uszy (1966)", "Ściany mają uszy", 1966,
[Movie(1, "Something Else", "Something Else", 1966), Movie(2, "Walls Have Ears", "Ściany mają uszy", 1966)],
2
},
// Regional release dates straddle the new year, so a year that is off by one still matches.
{
"Off by one year", "Some Movie", 2011,
[Movie(1, "Some Movie", "Some Movie", 2015), Movie(2, "Some Movie", "Some Movie", 2010)],
2
},
// Nothing matches the name, so TMDb's own ordering is kept.
{
"A Christmas No. 1 (2021)", "A Christmas No. 1", 2021,
[Movie(878111, "A Christmas Number One", "A Christmas Number One", 2021), Movie(2, "Ten Hours for Christmas", "10 Horas para o Natal", 2021)],
878111
},
// A title that matches always beats one that only shares the year.
{
"Title outranks year", "Some Movie", 2020,
[Movie(1, "A Different Movie", "A Different Movie", 2020), Movie(2, "Some Movie", "Some Movie", 1994)],
2
},
// Without a year the title alone decides, and equally good titles keep TMDb's order.
{
"No year known", "Mulan", 0,
[Movie(10674, "Mulan", "Mulan", 1998), Movie(337401, "Mulan", "Mulan", 2020)],
10674
},
// An unparsable name must not throw or reorder anything.
{
"Empty name", " ", 2020,
[Movie(1, "Some Movie", "Some Movie", 1994), Movie(2, "Some Movie", "Some Movie", 2020)],
1
}
};
private static SearchMovie Movie(int id, string title, string originalTitle, int year)
=> new()
{
Id = id,
Title = title,
OriginalTitle = originalTitle,
ReleaseDate = new DateTime(year, 6, 1, 0, 0, 0, DateTimeKind.Utc)
};
private static SearchTv Series(int id, string name, string originalName, int year)
=> new()
{
Id = id,
Name = name,
OriginalName = originalName,
FirstAirDate = new DateTime(year, 6, 1, 0, 0, 0, DateTimeKind.Utc)
};
}
}
@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.EntryPoints;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.EntryPoints;
public class LibraryChangedNotifierTests
{
// How long a test waits for the notifier's timer callback to run. Generous: the assertions are
// about a batch being sent at all, not about how promptly.
private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15);
private readonly Mock<ILibraryManager> _libraryManager = new();
private readonly Mock<IServerConfigurationManager> _configurationManager = new();
private readonly Mock<ISessionManager> _sessionManager = new();
private readonly Mock<IUserManager> _userManager = new();
private readonly Mock<IProviderManager> _providerManager = new();
private readonly ServerConfiguration _configuration = new();
private int _flushCount;
public LibraryChangedNotifierTests()
{
_configurationManager.SetupGet(e => e.Configuration).Returns(_configuration);
// Reading the session list is the first thing a flush does, so it stands in for "a batch was
// sent" without having to mock a whole user library behind it.
_sessionManager.SetupGet(e => e.Sessions)
.Returns(() =>
{
Interlocked.Increment(ref _flushCount);
return [];
});
}
[Fact]
public async Task OnLibraryItemUpdated_BatchSizeCapReached_SendsWithoutWaitingForWindow()
{
// Long enough that only the size cap can close the batch.
_configuration.LibraryUpdateDuration = 3600;
var notifier = CreateNotifier();
await notifier.StartAsync(TestContext.Current.CancellationToken);
for (var i = 0; i < LibraryChangedNotifier.MaxBatchSize; i++)
{
RaiseItemUpdated();
}
Assert.True(await WaitForFlushAsync(1), "The batch was not sent once it hit the size cap.");
await notifier.StopAsync(TestContext.Current.CancellationToken);
notifier.Dispose();
}
[Fact]
public async Task OnLibraryItemUpdated_ChangesNeverPause_StillSendsOnTheWindow()
{
// A scan changes items continuously. The window must run from the first change of a batch, or
// the batch never closes and holds every item it named alive for the length of the scan.
_configuration.LibraryUpdateDuration = 1;
var notifier = CreateNotifier();
await notifier.StartAsync(TestContext.Current.CancellationToken);
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0)
{
// Well below the window, and well below the size cap over the whole loop.
RaiseItemUpdated();
await Task.Delay(25, TestContext.Current.CancellationToken);
}
Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving.");
await notifier.StopAsync(TestContext.Current.CancellationToken);
notifier.Dispose();
}
private LibraryChangedNotifier CreateNotifier()
=> new(
_libraryManager.Object,
_configurationManager.Object,
_sessionManager.Object,
_userManager.Object,
NullLogger<LibraryChangedNotifier>.Instance,
_providerManager.Object);
// A folder passes the notifier's item filter without needing any of BaseItem's static services.
private void RaiseItemUpdated()
=> _libraryManager.Raise(
e => e.ItemUpdated += null,
_libraryManager.Object,
new ItemChangeEventArgs { Item = new Folder { Id = Guid.NewGuid() } });
private async Task<bool> WaitForFlushAsync(int expected)
{
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed < _flushTimeout)
{
if (Volatile.Read(ref _flushCount) >= expected)
{
return true;
}
await Task.Delay(25, TestContext.Current.CancellationToken);
}
return false;
}
}
@@ -0,0 +1,78 @@
using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.EntryPoints;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Session;
using Moq;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.EntryPoints;
public class UserDataChangeNotifierTests
{
// How long a test waits for the notifier's timer callback to run. Generous: the assertions are
// about a batch being sent at all, not about how promptly.
private static readonly TimeSpan _flushTimeout = TimeSpan.FromSeconds(15);
private readonly Mock<IUserDataManager> _userDataManager = new();
private readonly Mock<ISessionManager> _sessionManager = new();
private readonly Mock<IUserManager> _userManager = new();
private int _flushCount;
public UserDataChangeNotifierTests()
{
_sessionManager
.Setup(e => e.SendMessageToUserSessions(
It.IsAny<System.Collections.Generic.List<Guid>>(),
SessionMessageType.UserDataChanged,
It.IsAny<Func<UserDataChangeInfo>>(),
It.IsAny<CancellationToken>()))
.Callback(() => Interlocked.Increment(ref _flushCount))
.Returns(Task.CompletedTask);
}
[Fact]
public async Task OnUserDataSaved_ChangesNeverPause_StillSendsOnTheWindow()
{
// A scan changes user data continuously. The window must run from the first change of a batch,
// or the batch never closes and holds every item it named alive for the length of the scan.
var notifier = CreateNotifier();
await notifier.StartAsync(TestContext.Current.CancellationToken);
var userId = Guid.NewGuid();
var stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed < _flushTimeout && Volatile.Read(ref _flushCount) == 0)
{
// Well below the window, and well below the size cap over the whole loop.
RaiseUserDataSaved(userId);
await Task.Delay(25, TestContext.Current.CancellationToken);
}
Assert.True(Volatile.Read(ref _flushCount) > 0, "The batch was never sent while changes kept arriving.");
await notifier.StopAsync(TestContext.Current.CancellationToken);
notifier.Dispose();
}
private UserDataChangeNotifier CreateNotifier()
=> new(_userDataManager.Object, _sessionManager.Object, _userManager.Object);
// A folder needs none of BaseItem's static services, and PlaybackProgress is the one reason the
// notifier ignores outright.
private void RaiseUserDataSaved(Guid userId)
=> _userDataManager.Raise(
e => e.UserDataSaved += null,
_userDataManager.Object,
new UserDataSaveEventArgs
{
UserId = userId,
SaveReason = UserDataSaveReason.UpdateUserRating,
Item = new Folder { Id = Guid.NewGuid() }
});
}

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