Compare commits

...

202 Commits

Author SHA1 Message Date
Joshua M. Boniface
b2aa80ce5c Fix invalid regex comparison 2026-03-31 19:59:33 -04:00
Joshua M. Boniface
ff365dae34 Fix invalid merge conflict fix 2026-03-31 19:46:47 -04:00
Jellyfin Release Bot
52aebfb7d3 Bump version to 10.11.7 2026-03-31 19:33:11 -04:00
Joshua M. Boniface
66ea1b50e6 Merge commit from fork
Fix GHSA-8fw7-f233-ffr8 with improved sanitization
2026-03-31 19:17:17 -04:00
Joshua M. Boniface
3f656ade7a Merge remote-tracking branch 'upstream/release-10.11.z' into advisory-fix-1 2026-03-31 19:16:19 -04:00
Joshua M. Boniface
8bf0d372c6 Merge commit from fork
Fix GHSA-jh22-fw8w-2v9x
2026-03-31 17:46:01 -04:00
Joshua M. Boniface
202d7b5829 Merge branch 'release-10.11.z' into advisory-fix-1 2026-03-31 17:44:59 -04:00
Joshua M. Boniface
352e4f3aba Merge commit from fork
Fix GHSA v2jv-54xj-h76w
2026-03-31 17:43:02 -04:00
Joshua M. Boniface
c5f6d00c94 Merge commit from fork
Fix GHSA-j2hf-x4q5-47j3 with improved sanitization
2026-03-31 17:38:46 -04:00
Shadowghost
e8d1d94436 Lock down tuner API to be admin-only 2026-03-31 16:35:15 +02:00
Shadowghost
50dc37065b Fix GHSA-jh22-fw8w-2v9x 2026-03-31 09:30:45 +02:00
Niels van Velzen
7e88b18192 Merge pull request #16522 from Bond-009/CA1810
Fix CA1810 build error
2026-03-30 18:44:15 +02:00
Bond-009
89e914c7f1 Merge pull request #16519 from jellyfin/check-h264-profile-null
Fix Null was not checked before using the H264 profile
2026-03-30 18:39:45 +02:00
Bond_009
1932ac4765 Fix CA1810 build error 2026-03-30 18:33:56 +02:00
Bond-009
ec33c74ec4 Merge pull request #16440 from Molier/fix/subtitle-extraction-flush
Remove -copyts and add -flush_packets 1 to subtitle extraction
2026-03-30 18:30:58 +02:00
nyanmisaka
2184ed1b16 Fix Null was not checked before using the H264 profile
This is rare, but not impossible.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-03-30 20:51:11 +08:00
Shadowghost
d3907afde7 Add additional validations 2026-03-30 10:48:51 +02:00
theguymadmax
e12d933531 Revet lint fix 2026-03-30 04:21:26 -04:00
theguymadmax
c0ba29d917 fix lint issue 2026-03-30 04:14:23 -04:00
Shadowghost
d1fd81c382 Fix GHSA v2jv-54xj-h76w 2026-03-30 09:40:01 +02:00
Joshua M. Boniface
e038045494 Fix lint 2026-03-29 19:11:40 -04:00
Joshua M. Boniface
e1691e649e Merge pull request #16514 from theguymadmax/release-10.11.z-fixup 2026-03-29 19:04:53 -04:00
theguymadmax
8d28497d29 Fix lint issue 2026-03-29 18:25:57 -04:00
Joshua M. Boniface
fddd4e7e6b Fix GHSA-8fw7-f233-ffr8 with improved sanitization
Co-Authored-By: Shadowghost <Ghost_of_Stone@web.de>
2026-03-29 17:30:09 -04:00
Joshua M. Boniface
0581cd6610 Fix GHSA-j2hf-x4q5-47j3 with improved sanitization
Co-Authored-By: Shadowghost <Ghost_of_Stone@web.de>
2026-03-29 17:22:14 -04:00
Joshua M. Boniface
0f1732e5f5 Merge pull request #16425 from theguymadmax/fix-metadata-backup 2026-03-29 15:22:14 -04:00
Bond-009
41c2d51d8c Merge pull request #16369 from Bond-009/skiafonts
Fix nullref ex in font handling
2026-03-29 20:27:17 +02:00
Bond-009
29b2361857 Merge pull request #16423 from nyanmisaka/fix-ffmpeg8-readrate-option
Fix readrate options in FFmpeg 8.1
2026-03-23 21:25:35 +01:00
Bond-009
ce867f9834 Merge pull request #16449 from theguymadmax/fix-collection-number
Fix NFO saver using wrong provider ID for collectionnumber
2026-03-23 19:23:34 +01:00
theguymadmax
4034bf9d7e Save collection id instead of moive id 2026-03-22 12:49:33 -04:00
Oscar
3d2658fa43 Remove -copyts and add -flush_packets 1 to subtitle extraction
-copyts is unnecessary for -c:s copy to SRT and slows extraction ~5x.
Without -flush_packets 1, ffmpeg buffers all output until exit, leaving
.srt files at 0 bytes for minutes while the player shows no subtitles.

Fixes #16438
2026-03-19 22:54:05 +01:00
theguymadmax
61b19688ff Backup default metadata location 2026-03-17 23:13:20 -04:00
theguymadmax
e8d72bf6a3 Fix restore backup metadata location 2026-03-16 10:32:09 -04:00
nyanmisaka
348b14f7b7 Fix readrate options in FFmpeg 8.1
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-03-16 17:58:53 +08:00
IceStormNG
fda49a5a49 Apply analyzeduration and probesize for subtitle streams to improve codec parameter detection (#16293)
Apply analyzeduration and probesize for subtitle streams to improve codec parameter detection
2026-03-13 20:26:25 +01:00
Bond-009
55c00d76bb Merge pull request #16392 from nyanmisaka/fix-ffmpeg8-filter-detection
Fix filter detection in FFmpeg 8.1
2026-03-13 20:24:18 +01:00
nyanmisaka
519d2113eb Fix filter detection in FFmpeg 8.1
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-03-11 17:46:07 +08:00
Bond_009
f34f6b6941 Fix nullref ex in font handling
Don't add fallback fonts if they are null
The default font can still be null, however unlikely
2026-03-08 12:46:25 +01:00
Joshua M. Boniface
6864e108b8 Merge pull request #16257 from lowbit/fix/subtitle-empty-file-cache 2026-03-07 00:53:56 -05:00
Bond-009
09ba04662a Merge pull request #16341 from crimsonspecter/fix-fractional-hls-time-for-remux
Fix hls segment length adjustment for remuxed content
2026-03-06 22:39:59 +01:00
crimsonspecter
9cd2418095 Fix: don't apply segment length adjustment for remuxed content 2026-03-04 20:27:06 +01:00
Bond-009
b6a96513de Merge pull request #16253 from theguymadmax/use-backupdatabase
Checkpoint WAL before moving library.db in migration
2026-02-28 10:08:36 +01:00
Bond-009
ca57166e95 Merge pull request #16204 from MBR-0001/fix-bad-subtitle-settings
Fix broken library subtitle download settings
2026-02-28 10:08:20 +01:00
MBR#0001
33496c1693 Fix broken library subtitle download settings 2026-02-26 19:54:24 +01:00
Bond-009
b65daeca0b Merge pull request #16150 from dfederm/nullref-season-series
Fix nullref in Season.GetEpisodes when the season is detached from a series
2026-02-22 13:49:41 +01:00
David Federman
286cc6d720 Fix nullref in Season.GetEpisodes when the season is detached from a series 2026-02-20 22:56:30 -08:00
Andrew Rabert
aa4f09c799 Mitigate pull_request_target privilege escalation
Hotfix — replaces pull_request_target with pull_request to stop
granting write permissions and secrets to fork PRs. Some workflows
will break; can be fixed properly later.
2026-02-20 19:10:40 -05:00
rijads
afd3c0d9f3 Fix subtitle extraxtion caching empty files 2026-02-19 17:53:47 +01:00
theguymadmax
5597d8e1a7 Checkpoint wal 2026-02-18 15:11:57 -05:00
theguymadmax
0166362258 Use BackupDatabase() instead of File.Move in library.db migration 2026-02-17 22:56:45 -05:00
Bond-009
58c330b63d Merge pull request #16226 from dfederm/fix/16134-migration-unique-constraint
Deduplicate provider IDs during MigrateLibraryDb migration
2026-02-14 11:46:43 +01:00
Bond-009
be71295693 Merge pull request #16227 from dfederm/fix/16149-watch-state-episode-replace
Reattach user data after item removal during library scan
2026-02-14 11:45:51 +01:00
Bond-009
8cd3090cee Merge pull request #16231 from theguymadmax/skip-image-for-empty-folders
Skip image checks for empty folders
2026-02-14 11:43:24 +01:00
David Federman
7bf08daeec Reattach user data after removing items during library scan
When items are removed during a library scan, their user data is
detached to a placeholder. If a replacement item already exists
(e.g., a new version of the same episode was added before the old
file was deleted), the user data would be stranded in the placeholder
because the replacement item's initial ReattachUserDataAsync call
happened before the old item was deleted.

This fix checks for remaining valid children that share user data
keys with removed items and reattaches any detached user data to them.

Fixes #16149
2026-02-12 20:38:28 -08:00
David Federman
290463fe7b Fix migration UNIQUE constraint on BaseItemProviders
Deduplicate ProviderIds by ProviderId during MigrateLibraryDb migration
to prevent UNIQUE constraint violations when legacy data contains
duplicate provider entries for the same item.

Fixes #16134
2026-02-12 20:37:26 -08:00
theguymadmax
1b2d9c100a Skip image checks for empty folders 2026-02-12 18:04:27 -05:00
Bond-009
caa05c1bf2 Merge pull request #16116 from saltpi/bugfix
Fix TMDB image URLs missing size parameter
2026-02-02 20:48:21 +01:00
Niels van Velzen
a37ead86df Merge pull request #16098 from theguymadmax/fix-random-sort
Fix random sort returning duplicate items
2026-01-27 11:31:41 +01:00
Niels van Velzen
e65aff8bc6 Merge pull request #16109 from nielsvanvelzen/ws-session-info-dto-backport
Fix SessionInfoWebSocketListener not using SessionInfoDto
2026-01-27 11:31:15 +01:00
endpne
9734494eb6 Fix TMDB image URLs missing size parameter 2026-01-26 13:25:03 +08:00
Niels van Velzen
d41e302418 Fix SessionInfoWebSocketListener not using SessionInfoDto 2026-01-25 21:21:48 +01:00
theguymadmax
80ba517294 Fix random sort returning duplicate items 2026-01-24 13:48:05 -05:00
MarcoCoreDuo
95d08b264f Rehydrate cached UserData after reattachment (#16071) 2026-01-22 17:43:05 -07:00
IceStormNG
893a849f28 Slightly adjust segment length for fractional framerates (#16053)
Co-authored-by: Carsten Braun <carsten.braun@braun-cloud.de>
2026-01-22 17:41:51 -07:00
theguymadmax
673f617994 Fix TMDB crew department mapping (#16066) 2026-01-22 17:40:35 -07:00
theguymadmax
644327eb76 Revert hidden directory ignore pattern (#16077) 2026-01-22 17:39:55 -07:00
Jellyfin Release Bot
10662e75e4 Bump version to 10.11.6 2026-01-18 20:02:59 -05:00
Joshua M. Boniface
a2b1936e73 Merge pull request #15816 from theguymadmax/preserve-artist-order
Fix artist display order
2026-01-18 19:48:17 -05:00
theguymadmax
2df546af6d Deduplicate using Distinct 2026-01-18 18:16:45 -05:00
Claus Vium
338b480217 Merge pull request #16046 from theguymadmax/restore-weekly-images
Restore weekly refresh for library folder images
2026-01-18 16:36:45 +01:00
theguymadmax
2943bb6fdd Restore collection folder image refresh 2026-01-18 01:51:51 -05:00
theguymadmax
94edcbd2d1 Fix artist ordering DtoServices 2026-01-17 10:14:41 -05:00
theguymadmax
a8d1cdefac Address review comments 2026-01-17 10:14:41 -05:00
Tim Eisele
a518160a6f Prioritize better matches on search (#15983) 2026-01-16 19:05:46 -07:00
Tim Eisele
b56de6493f Be more strict about PersonType assignments (#15872) 2026-01-16 19:03:13 -07:00
theguymadmax
093cfc3f3b Trim music artist names (#15808) 2026-01-16 18:51:48 -07:00
theguymadmax
49775b1f6a Fix birthplace not saving correctly (#16020) 2026-01-16 18:47:40 -07:00
Collin T Swisher
22d593b8e9 Add mblink creation logic to library update endpoint. (#15965) 2026-01-16 18:47:04 -07:00
theguymadmax
2cb7fb52d2 Skip hidden directories and .ignore paths in library monitoring (#16029) 2026-01-16 18:45:19 -07:00
Joshua M. Boniface
8433b6d8a4 Merge pull request #15899 from MarcoCoreDuo/fix-watch-state-not-kept
Fix watched state not kept on Media replace/rename
2026-01-16 16:40:36 -05:00
Bond-009
32d2414de0 Merge pull request #15950 from theguymadmax/revert-sort-index-number
Revert "always sort season by index number"
2026-01-09 18:38:23 +01:00
Bond-009
317a3a47c3 Merge pull request #15961 from theguymadmax/fix-bad-plugin-url
Fix crash when plugin repository has an invalid URL
2026-01-09 18:24:48 +01:00
theguymadmax
845b8cdc8f Fix crash when plugin repository has an invalid URL 2026-01-06 11:57:25 -05:00
theguymadmax
c86f6439c5 Revert "always sort season by index number"
This reverts commit e16ea7b236.
2026-01-05 11:06:25 -05:00
theguymadmax
559e0088e5 Fix tag inheritance for Continue Watching queries (#15931) 2026-01-04 11:20:34 -07:00
MarcoCoreDuo
adaca95590 make db context creation async 2025-12-31 07:43:07 +01:00
MarcoCoreDuo
09a1c31fa3 Refactor ReattachUserData methods to be asynchronous 2025-12-31 03:06:07 +01:00
MarcoCoreDuo
e4b82025b8 move reattaching user data to own function and call it only after fetching metadata for the first time 2025-12-30 22:04:59 +01:00
Collin T Swisher
78e3702cb0 Fix playlist item de-duplication (#15858) 2025-12-24 07:50:15 -07:00
Bond-009
01b20d3b75 Merge pull request #15833 from nyanmisaka/fix-h264-av1-sdr-hls-fallback
Fix missing H.264 and AV1 SDR fallbacks in HLS playlist
2025-12-24 10:28:33 +01:00
Tim Eisele
156761405e Prefer US rating on fallback (#15793) 2025-12-19 20:41:09 -07:00
Claus Vium
1805f2259f add CultureDto cache (#15826) 2025-12-19 20:38:54 -07:00
Nyanmisaka
4c587776d6 Fix the use of HWA in unsupported H.264 Hi422P/Hi444PP (#15819) 2025-12-19 19:58:56 -07:00
gnattu
8379b4634a Enforce more strict webm check (#15807) 2025-12-19 19:57:08 -07:00
Nyanmisaka
9470439cfa Fix video lacking SAR and DAR are marked as anamorphic (#15834) 2025-12-19 19:54:48 -07:00
gnattu
18096e48e0 Use hvc1 codectag for Dolby Vision 8.4 (#15835) 2025-12-19 19:53:28 -07:00
nyanmisaka
f2d0ac7b28 Fix missing H.264 and AV1 SDR fallbacks in HLS playlist
Previously, if HEVC encoding was disabled on the server,
SDR fallbacks would not be provided.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2025-12-19 20:33:24 +08:00
theguymadmax
2ccf08f547 Fix artist display order 2025-12-17 01:09:13 -05:00
Jellyfin Release Bot
1e27f460fe Bump version to 10.11.5 2025-12-14 21:44:14 -05:00
Andrew Rabert
4cdd8c8233 Fix unnecessary database JOINs in ApplyNavigations (#15666) 2025-12-13 10:58:08 -07:00
Tim Eisele
6e60634c9f Skip invalid ignore rules (#15746) 2025-12-13 08:39:49 -07:00
theguymadmax
12c5d6b636 Fix backdrop images being deleted when stored with media (#15766) 2025-12-13 08:29:17 -07:00
theguymadmax
b617c62f8e Fix NullReferenceException in ApplyOrder method (#15768) 2025-12-13 08:28:31 -07:00
Nyanmisaka
035b5895b0 Fix AV1 decoding hang regression on RK3588 (#15776) 2025-12-13 08:27:29 -07:00
theguymadmax
22da5187c8 Fix collection display order (#15767) 2025-12-13 08:27:01 -07:00
theguymadmax
5804d6840c Fix parental rating comparison with sub-scores (#15786) 2025-12-13 08:25:48 -07:00
Bond-009
b50ce1ad6b Merge pull request #15752 from Collin-Swish/fix-name-case-insensitivity
Fix case sensitivity edge case
2025-12-12 21:39:22 +01:00
Bond-009
481ee03f35 Merge pull request #15757 from theguymadmax/fix-trickplays-for-alt-versions
Fix trickplay images using wrong item on alternate versions
2025-12-12 21:31:52 +01:00
Bond-009
d91adb5d54 Merge pull request #15662 from SapientGuardian/issue15661
Fix blocking in async context in LimitedConcurrencyLibraryScheduler
2025-12-10 20:37:57 +01:00
theguymadmax
ef7f138a4e Fix trickplay images using wrong item on alternate versions 2025-12-09 14:21:09 -05:00
Collin Swisher
2e8d9a311b Fix case sensitivity edge case 2025-12-08 17:41:48 -06:00
gnattu
4c5a3fbff3 Use original name for MusicAritist matching (#15689) 2025-12-05 19:30:02 -07:00
liszto
636908fc4d Fix thumbnails not being deleted from temp folder 2025-12-05 19:29:54 -07:00
Tim Eisele
997362fc97 Backport dependency updates (#15723) 2025-12-05 19:27:30 -07:00
Noah Potash
c5147341e3 Fixes 15661. Replace BlockingCollection with Channel in LimitedConcurrencyLibraryScheduler to prevent blocking in an asynchronous context. 2025-12-03 21:50:08 -05:00
Noah Potash
ca33bcebf0 Add SapientGuardian to CONTRIBUTORS.md 2025-12-03 21:27:26 -05:00
Ivan Kara
d32f487e8e Fix symlinked file size (#15681) 2025-12-03 19:04:59 -07:00
theguymadmax
fb65f8f853 Fix ItemAdded event triggering when updating metadata (#15680) 2025-12-03 19:02:55 -07:00
martenumberto
2a0b90e385 Fix: Add .ts fallback for video streams to prevent crash (#15690) 2025-12-03 19:02:39 -07:00
myzhysz
dde70fd8a2 Fix stack overflow while scanning (#15698) 2025-12-03 19:02:04 -07:00
Niels van Velzen
98d1d0cb35 Merge pull request #15670 from nyanmisaka/fix-mjpeg-rk3576
Fix the empty output of trickplay on RK3576
2025-12-02 13:48:51 +01:00
Jellyfin Release Bot
ba76a8f3ad Bump version to 10.11.4 2025-11-30 21:33:32 -05:00
Anthony Lavado
8cd5652157 Merge pull request #15672 from jellyfin/openapi-cache-z
Cache OpenApi document generation
2025-11-30 21:22:29 -05:00
crobibero
8aff4227d9 Implement caching for OpenAPI document 2025-11-30 09:19:19 -07:00
nyanmisaka
026f7472cb Fix the empty output of trickplay on RK3576
Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2025-11-30 21:38:47 +08:00
MBR-0001
daca285568 Revert "Localization/iso6392.txt: change pob and pop" (#15555) 2025-11-23 19:20:29 +01:00
theguymadmax
fbb9a0b2c7 Fix ResolveLinkTarget crashing on exFAT drives (#15568) 2025-11-21 21:14:39 -07:00
Ziyuan Qu
29b3aa8543 Add hidden file check in bdInfo (#15582) 2025-11-21 21:14:30 -07:00
theguymadmax
94f3725208 Fix isMovie filter logic (#15594) 2025-11-21 21:14:03 -07:00
theguymadmax
0ee81e87be Fix locked fields on not saving (#15564) 2025-11-19 17:02:53 +01:00
theguymadmax
c491a918c2 Save item to database before providers run to prevent FK constraint errors (#15563) 2025-11-19 17:01:13 +01:00
gnattu
1e7e46cb82 Prevent copying HDR streams when only SDR is supported (#15556) 2025-11-18 18:37:35 -07:00
theguymadmax
5ae444d96d Fix NullReferenceException in filesystem path comparison (#15548) 2025-11-18 18:37:09 -07:00
gnattu
ee7ad83427 Restrict first video frame probing to file protocol (#15557) 2025-11-18 18:36:59 -07:00
Jellyfin Release Bot
921d7d3364 Bump version to 10.11.3 2025-11-16 17:40:07 -05:00
theguymadmax
f8e012582a Fix movie titles using folder name when NFOs saver is enabled (#15529) 2025-11-16 13:59:58 -07:00
theguymadmax
def5956cd1 Fix tmdbid not detected in single movie folder (#14955) 2025-11-16 13:36:35 -07:00
theguymadmax
abfbaca336 Fix series DateLastMediaAdded not updating when new episodes are added (#15472) 2025-11-16 13:35:43 -07:00
theguymadmax
6566188e45 Add 1 minute tolerance for NFO change detection (#15514) 2025-11-15 08:39:25 -07:00
theguymadmax
078f9584ed Fix playlist DateCreated and DateLastMediaAdded not being set (#15508) 2025-11-14 15:19:40 -07:00
Iksas
ee34c75386 fix missing font extraction for certain transcoding settings (#15502) 2025-11-13 18:30:18 -07:00
theguymadmax
e8150428b6 Fix .ignore handling for directories (#15501) 2025-11-13 18:23:18 -07:00
theguymadmax
4b38e35bbb Remove InheritedTags and update tag filtering logic (#15493) 2025-11-13 18:23:03 -07:00
Huo Jiacheng
435bb14bb2 Fix gitignore-style not working properly on windows. (#15487) 2025-11-12 19:43:13 -07:00
theguymadmax
2e5ced5098 Improve season folder parsing (#15404) 2025-11-12 17:36:57 -07:00
Bond-009
f4a846aa4d Don't error out when searching for marker files fails (#15466)
Fixes #15445
2025-11-11 15:45:47 -07:00
Joshua M. Boniface
7c1063177f Merge pull request #15462 from theguymadmax/fix-exception-for-empty-strm-files
Fix NullReferenceException in GetPathProtocol when path is null
2025-11-10 19:30:38 -05:00
Joshua M. Boniface
5878b1ffc5 Merge pull request #15468 from Bond-009/carefulWithLastMinChanges
Check if target exists before trying to follow it
2025-11-10 19:12:24 -05:00
Bond_009
3c3c2aee0d Check if target exists before trying to follow it
Exception got caught in ManagedFileSystem and wrong file info got returned
2025-11-10 23:19:17 +01:00
theguymadmax
511223aac4 Fix NullReferenceException in GetPathProtocol when path is null 2025-11-10 02:30:49 -05:00
Mikal S.
3b2d64995a Resolve symlinks for static media source infos (#15263) 2025-11-09 09:45:02 -07:00
theguymadmax
13c4517a66 Fix collection grouping in mixed libraries (#15373) 2025-11-09 09:35:50 -07:00
theguymadmax
177b6464ca Don't clear baseitemids (#15446) 2025-11-09 09:22:09 -07:00
Bond-009
5a9a8363f4 Merge pull request #15441 from IceStormNG/fix-nullreference-role-null-10.11
Fix System.NullReferenceException when people's role is null (10.11.z)
2025-11-08 18:25:03 +01:00
theguymadmax
49efd68fc7 Invalidate parent folder's cache on deletion/creation (#15423) 2025-11-08 08:30:04 -07:00
Carsten Braun
90a8a26c6e Copy-Pasting is sometimes hard.... 2025-11-08 15:00:11 +01:00
Carsten Braun
002c83e6f5 Fix NullReferenceExceltop when role is null. 2025-11-08 14:32:14 +01:00
theguymadmax
7222910b05 Fix filters to use SortName (#15381) 2025-11-07 18:21:41 -07:00
Bond-009
097cb87f6f Don't enforce a minimum amount of free space for the tmp and log dirs (#15390) 2025-11-07 18:21:10 -07:00
JPVenson
91c3b1617e Fixed missing sort argument (#15413) 2025-11-07 18:20:42 -07:00
theguymadmax
8f71922734 Fix item count display for collapsed items (#15380) 2025-11-07 18:20:10 -07:00
Niels van Velzen
d140630208 Update branding in Swagger page (#15422) 2025-11-07 18:19:30 -07:00
theguymadmax
63a3e55297 Fix search terms using diacritics (#15435) 2025-11-07 18:18:24 -07:00
evanreichard
c2e5081d64 feat(sqlite): add timeout config (#15369) 2025-11-07 18:17:43 -07:00
Jellyfin Release Bot
4187c6f620 Bump version to 10.11.2 2025-11-02 21:28:56 -05:00
Tim Eisele
e7dbb3afec Skip too large extracted season numbers (#15326) 2025-11-02 09:11:48 -07:00
vinnyspb
f994dd6211 Update file size when refreshing metadata (#15325) 2025-11-01 14:18:19 -06:00
Cody Robibero
da254ee968 return instead of break, add check to more migrations (#15322) 2025-11-01 14:17:22 -06:00
Bill Thornton
4ad3141875 Update password reset to always return the same response structure (#15254) 2025-11-01 14:17:09 -06:00
evanreichard
b5f0199a25 fix: in optimistic locking, key off table is locked (#15328) 2025-11-01 14:15:26 -06:00
Nyanmisaka
6bf88c049e Ignore initial delay in audio-only containers (#15247) 2025-10-29 20:40:28 -06:00
Jellyfin Release Bot
40a33da2a5 Bump version to 10.11.1 2025-10-26 22:02:09 -04:00
Joshua M. Boniface
3596fc0693 Fix bump_version to handle spaced filename 2025-10-26 21:50:38 -04:00
Jellyfin Release Bot
93824dad97 Bump version to 10.11.1 2025-10-26 21:41:27 -04:00
Tim Eisele
e5656af1f2 Improve symlink handling (#15209) 2025-10-26 15:10:13 -06:00
Niels van Velzen
c127c10458 Merge pull request #15225 from Bond-009/z440ATL
Update dependency z440.atl.core to 7.6.0
2025-10-26 18:50:04 +01:00
Tim Eisele
7d1824ea27 Fix pagination and sorting for folders (#15187) 2025-10-26 11:34:11 -06:00
Cody Robibero
2966d27c97 Skip invalid database migration (#15212) 2025-10-26 11:34:04 -06:00
Ivan Kara
618ec4543e Add season number fallback for OMDB and TMDB plugins (#15113) 2025-10-26 11:33:55 -06:00
Cody Robibero
0e4031ae52 Skip extracting directory entry when restoring (#15196) 2025-10-26 11:33:47 -06:00
CeruleanRed
442af96ed9 Only save chapters that are within the runtime of the video file (#15176) 2025-10-26 10:37:16 -06:00
JJBlue
a305204cfa Skip extracted files in migration if bad timestamp or no access (#15220)
Fixes #15024
2025-10-26 10:30:43 -06:00
theguymadmax
75f472e6a7 Normalize paths in database queries (#15217) 2025-10-26 10:30:12 -06:00
Bond_009
cc32e8f7cb Update dependency z440.atl.core to 7.6.0 2025-10-26 15:16:08 +01:00
MBR-0001
14b3085ff1 Fix Has(Imdb/Tmdb/Tvdb)Id checks (#15126) 2025-10-25 16:00:55 -06:00
Cody Robibero
5691eee4f1 Prefer filting by package id instead of name (#15197) 2025-10-25 09:37:09 -06:00
theguymadmax
1520a697ad Play selected song first with instant mix (#15133) 2025-10-25 09:33:11 -06:00
Cody Robibero
81b8b0ca4a Add the transcode marker during startup instead of first transcode (#15194) 2025-10-25 09:32:15 -06:00
Cody Robibero
ac3fa3c376 Clean up backup service (#15170) 2025-10-24 17:57:34 -06:00
Tim Eisele
7a1c1cd342 Skip extracted files in migration if bad timestamp or no access (#15112) 2025-10-24 17:57:19 -06:00
gnattu
70c32a26fa Make priority class setting more robust (#15177) 2025-10-24 17:57:02 -06:00
Cody Robibero
2b94bb54aa Fix xml formatter (#15164) 2025-10-24 17:56:38 -06:00
Bond-009
0a6e8146be Lower required tmp dir size to 512MiB (#15098) 2025-10-23 16:38:27 -06:00
theguymadmax
305b0fdca3 Make season paths case-insensitive (#15102) 2025-10-23 16:38:06 -06:00
theguymadmax
d738386fe2 Fix LiveTV images not saving to database (#15083) 2025-10-23 16:37:55 -06:00
Tim Eisele
ca830d5be7 Speed-up trickplay migration (#15054) 2025-10-23 16:37:47 -06:00
theguymadmax
a5bc4524d8 Optimize artist query (#15087) 2025-10-23 16:37:29 -06:00
Nyanmisaka
175ee12bbc Fix videos with cropping metadata are probed as anamorphic (#15144) 2025-10-23 16:31:11 -06:00
Nyanmisaka
a725220c21 Reject stream copy of HDR10+ video if the client does not support HDR10 (#15072) 2025-10-21 17:20:56 -06:00
gnattu
a245605152 Log the message more clear when network manager is not ready (#15055) 2025-10-21 17:18:26 -06:00
Tim Eisele
f4a53209f4 Skip invalid keyframe cache data (#15032) 2025-10-21 17:17:56 -06:00
Jellyfin Release Bot
877251bcae Bump version to 10.11.0 2025-10-19 20:45:12 -04:00
133 changed files with 2613 additions and 1250 deletions

View File

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

View File

@@ -20,18 +20,18 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with: with:
dotnet-version: '9.0.x' dotnet-version: '9.0.x'
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
queries: +security-extended queries: +security-extended
- name: Autobuild - name: Autobuild
uses: github/codeql-action/autobuild@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 uses: github/codeql-action/autobuild@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6

View File

@@ -1,6 +1,6 @@
name: ABI Compatibility name: ABI Compatibility
on: on:
pull_request_target: pull_request:
permissions: {} permissions: {}
@@ -11,13 +11,13 @@ jobs:
permissions: read-all permissions: read-all
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with: with:
dotnet-version: '9.0.x' dotnet-version: '9.0.x'
@@ -26,7 +26,7 @@ jobs:
dotnet build Jellyfin.Server -o ./out dotnet build Jellyfin.Server -o ./out
- name: Upload Head - name: Upload Head
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: abi-head name: abi-head
retention-days: 14 retention-days: 14
@@ -40,14 +40,14 @@ jobs:
permissions: read-all permissions: read-all
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
fetch-depth: 0 fetch-depth: 0
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with: with:
dotnet-version: '9.0.x' dotnet-version: '9.0.x'
@@ -65,7 +65,7 @@ jobs:
dotnet build Jellyfin.Server -o ./out dotnet build Jellyfin.Server -o ./out
- name: Upload Head - name: Upload Head
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: abi-base name: abi-base
retention-days: 14 retention-days: 14
@@ -77,7 +77,7 @@ jobs:
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment) pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
name: ABI - Difference name: ABI - Difference
if: ${{ github.event_name == 'pull_request_target' }} if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- abi-head - abi-head
@@ -85,13 +85,13 @@ jobs:
steps: steps:
- name: Download abi-head - name: Download abi-head
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with: with:
name: abi-head name: abi-head
path: abi-head path: abi-head
- name: Download abi-base - name: Download abi-base
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with: with:
name: abi-base name: abi-base
path: abi-base path: abi-base

View File

@@ -5,7 +5,7 @@ on:
- master - master
tags: tags:
- 'v*' - 'v*'
pull_request_target: pull_request:
permissions: {} permissions: {}
@@ -16,18 +16,18 @@ jobs:
permissions: read-all permissions: read-all
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with: with:
dotnet-version: '9.0.x' dotnet-version: '9.0.x'
- name: Generate openapi.json - name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json - name: Upload openapi.json
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: openapi-head name: openapi-head
retention-days: 14 retention-days: 14
@@ -41,7 +41,7 @@ jobs:
permissions: read-all permissions: read-all
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ github.event.pull_request.head.sha }} ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }} repository: ${{ github.event.pull_request.head.repo.full_name }}
@@ -55,13 +55,13 @@ jobs:
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF) ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
git checkout --progress --force $ANCESTOR_REF git checkout --progress --force $ANCESTOR_REF
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with: with:
dotnet-version: '9.0.x' dotnet-version: '9.0.x'
- name: Generate openapi.json - name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json - name: Upload openapi.json
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0
with: with:
name: openapi-base name: openapi-base
retention-days: 14 retention-days: 14
@@ -73,19 +73,19 @@ jobs:
pull-requests: write # to create or update comment (peter-evans/create-or-update-comment) pull-requests: write # to create or update comment (peter-evans/create-or-update-comment)
name: OpenAPI - Difference name: OpenAPI - Difference
if: ${{ github.event_name == 'pull_request_target' }} if: ${{ github.event_name == 'pull_request' }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- openapi-head - openapi-head
- openapi-base - openapi-base
steps: steps:
- name: Download openapi-head - name: Download openapi-head
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with: with:
name: openapi-head name: openapi-head
path: openapi-head path: openapi-head
- name: Download openapi-base - name: Download openapi-base
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with: with:
name: openapi-base name: openapi-base
path: openapi-base path: openapi-base
@@ -148,7 +148,7 @@ jobs:
publish-unstable: publish-unstable:
name: OpenAPI - Publish Unstable Spec name: OpenAPI - Publish Unstable Spec
if: ${{ github.event_name != 'pull_request_target' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }} if: ${{ github.event_name != 'pull_request' && !startsWith(github.ref, 'refs/tags/v') && contains(github.repository_owner, 'jellyfin') }}
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: needs:
- openapi-head - openapi-head
@@ -158,7 +158,7 @@ jobs:
run: |- run: |-
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
- name: Download openapi-head - name: Download openapi-head
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with: with:
name: openapi-head name: openapi-head
path: openapi-head path: openapi-head
@@ -172,7 +172,7 @@ jobs:
strip_components: 1 strip_components: 1
target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}" target: "/srv/incoming/openapi/unstable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (unstable) into place - name: Move openapi.json (unstable) into place
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2 uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
with: with:
host: "${{ secrets.REPO_HOST }}" host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}" username: "${{ secrets.REPO_USER }}"
@@ -220,7 +220,7 @@ jobs:
run: |- run: |-
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Download openapi-head - name: Download openapi-head
uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0
with: with:
name: openapi-head name: openapi-head
path: openapi-head path: openapi-head
@@ -234,7 +234,7 @@ jobs:
strip_components: 1 strip_components: 1
target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}" target: "/srv/incoming/openapi/stable/jellyfin-openapi-${{ env.JELLYFIN_VERSION }}"
- name: Move openapi.json (stable) into place - name: Move openapi.json (stable) into place
uses: appleboy/ssh-action@2ead5e36573f08b82fbfce1504f1a4b05a647c6f # v1.2.2 uses: appleboy/ssh-action@823bd89e131d8d508129f9443cad5855e9ba96f0 # v1.2.4
with: with:
host: "${{ secrets.REPO_HOST }}" host: "${{ secrets.REPO_HOST }}"
username: "${{ secrets.REPO_USER }}" username: "${{ secrets.REPO_USER }}"

View File

@@ -20,9 +20,9 @@ jobs:
runs-on: "${{ matrix.os }}" runs-on: "${{ matrix.os }}"
steps: steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/setup-dotnet@d4c94342e560b34958eacfc5d055d21461ed1c5d # v5.0.0 - uses: actions/setup-dotnet@2016bd2012dba4e32de620c46fe006a3ac9f0602 # v5.0.1
with: with:
dotnet-version: ${{ env.SDK_VERSION }} dotnet-version: ${{ env.SDK_VERSION }}
@@ -35,7 +35,7 @@ jobs:
--verbosity minimal --verbosity minimal
- name: Merge code coverage results - name: Merge code coverage results
uses: danielpalme/ReportGenerator-GitHub-Action@9870ed167742d546b99962ff815fcc1098355ed8 # v5.4.17 uses: danielpalme/ReportGenerator-GitHub-Action@ee0ae774f6d3afedcbd1683c1ab21b83670bdf8e # v5.5.1
with: with:
reports: "**/coverage.cobertura.xml" reports: "**/coverage.cobertura.xml"
targetdir: "merged/" targetdir: "merged/"

View File

@@ -4,7 +4,7 @@ on:
types: types:
- created - created
- edited - edited
pull_request_target: pull_request:
types: types:
- labeled - labeled
- synchronize - synchronize
@@ -24,7 +24,7 @@ jobs:
reactions: '+1' reactions: '+1'
- name: Checkout the latest code - name: Checkout the latest code
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
token: ${{ secrets.JF_BOT_TOKEN }} token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0 fetch-depth: 0
@@ -40,11 +40,11 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: pull in script - name: pull in script
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
repository: jellyfin/jellyfin-triage-script repository: jellyfin/jellyfin-triage-script
- name: install python - name: install python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with: with:
python-version: '3.14' python-version: '3.14'
cache: 'pip' cache: 'pip'

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }} if: ${{ contains(github.repository, 'jellyfin/') }}
steps: steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with: with:
repo-token: ${{ secrets.JF_BOT_TOKEN }} repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true ascending: true

View File

@@ -10,11 +10,11 @@ jobs:
issues: write issues: write
steps: steps:
- name: pull in script - name: pull in script
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
repository: jellyfin/jellyfin-triage-script repository: jellyfin/jellyfin-triage-script
- name: install python - name: install python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0 uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548 # v6.1.0
with: with:
python-version: '3.14' python-version: '3.14'
cache: 'pip' cache: 'pip'

View File

@@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- master - master
pull_request_target: pull_request:
issue_comment: issue_comment:
permissions: {} permissions: {}

View File

@@ -4,7 +4,7 @@ on:
push: push:
branches: branches:
- master - master
pull_request_target: pull_request:
issue_comment: issue_comment:
permissions: {} permissions: {}
@@ -16,7 +16,7 @@ jobs:
steps: steps:
- name: Apply label - name: Apply label
uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3 uses: eps1lon/actions-label-merge-conflict@1df065ebe6e3310545d4f4c4e862e43bdca146f0 # v3.0.3
if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}} if: ${{ github.event_name == 'push' || github.event_name == 'pull_request'}}
with: with:
dirtyLabel: 'merge conflict' dirtyLabel: 'merge conflict'
commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.' commentOnDirty: 'This pull request has merge conflicts. Please resolve the conflicts so the PR can be successfully reviewed and merged.'

View File

@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }} if: ${{ contains(github.repository, 'jellyfin/') }}
steps: steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with: with:
repo-token: ${{ secrets.JF_BOT_TOKEN }} repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true ascending: true

View File

@@ -33,7 +33,7 @@ jobs:
yq-version: v4.9.8 yq-version: v4.9.8
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ env.TAG_BRANCH }} ref: ${{ env.TAG_BRANCH }}
@@ -66,7 +66,7 @@ jobs:
NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }} NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }}
steps: steps:
- name: Checkout Repository - name: Checkout Repository
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with: with:
ref: ${{ env.TAG_BRANCH }} ref: ${{ env.TAG_BRANCH }}

View File

@@ -117,6 +117,7 @@
- [sachk](https://github.com/sachk) - [sachk](https://github.com/sachk)
- [sammyrc34](https://github.com/sammyrc34) - [sammyrc34](https://github.com/sammyrc34)
- [samuel9554](https://github.com/samuel9554) - [samuel9554](https://github.com/samuel9554)
- [SapientGuardian](https://github.com/SapientGuardian)
- [scheidleon](https://github.com/scheidleon) - [scheidleon](https://github.com/scheidleon)
- [sebPomme](https://github.com/sebPomme) - [sebPomme](https://github.com/sebPomme)
- [SegiH](https://github.com/SegiH) - [SegiH](https://github.com/SegiH)
@@ -205,6 +206,8 @@
- [theshoeshiner](https://github.com/theshoeshiner) - [theshoeshiner](https://github.com/theshoeshiner)
- [TokerX](https://github.com/TokerX) - [TokerX](https://github.com/TokerX)
- [GeneMarks](https://github.com/GeneMarks) - [GeneMarks](https://github.com/GeneMarks)
- [martenumberto](https://github.com/martenumberto)
- [MarcoCoreDuo](https://github.com/MarcoCoreDuo)
# Emby Contributors # Emby Contributors

View File

@@ -4,7 +4,7 @@
</PropertyGroup> </PropertyGroup>
<!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.--> <!-- Run "dotnet list package (dash,dash)outdated" to see the latest versions of each package.-->
<ItemGroup Label="Package Dependencies"> <ItemGroup Label="Package Dependencies">
<PackageVersion Include="AsyncKeyedLock" Version="7.1.7" /> <PackageVersion Include="AsyncKeyedLock" Version="7.1.8" />
<PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" /> <PackageVersion Include="AutoFixture.AutoMoq" Version="4.18.1" />
<PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" /> <PackageVersion Include="AutoFixture.Xunit2" Version="4.18.1" />
<PackageVersion Include="AutoFixture" Version="4.18.1" /> <PackageVersion Include="AutoFixture" Version="4.18.1" />
@@ -17,7 +17,7 @@
<PackageVersion Include="Diacritics" Version="4.0.17" /> <PackageVersion Include="Diacritics" Version="4.0.17" />
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
<PackageVersion Include="DotNet.Glob" Version="3.1.3" /> <PackageVersion Include="DotNet.Glob" Version="3.1.3" />
<PackageVersion Include="FsCheck.Xunit" Version="3.3.1" /> <PackageVersion Include="FsCheck.Xunit" Version="3.3.2" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" /> <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.1" />
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" /> <PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" /> <PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
@@ -26,33 +26,33 @@
<PackageVersion Include="libse" Version="4.0.12" /> <PackageVersion Include="libse" Version="4.0.12" />
<PackageVersion Include="LrcParser" Version="2025.623.0" /> <PackageVersion Include="LrcParser" Version="2025.623.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="6.1.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.10" /> <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="9.0.11" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.10" /> <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="9.0.11" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" /> <PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="3.11.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.10" /> <PackageVersion Include="Microsoft.Data.Sqlite" Version="9.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.10" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.10" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.11" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.10" /> <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.Http" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="9.0.11" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.10" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="9.0.11" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.0.1" />
<PackageVersion Include="MimeTypes" Version="2.5.2" /> <PackageVersion Include="MimeTypes" Version="2.5.2" />
<PackageVersion Include="Morestachio" Version="5.0.1.631" /> <PackageVersion Include="Morestachio" Version="5.0.1.631" />
<PackageVersion Include="Moq" Version="4.18.4" /> <PackageVersion Include="Moq" Version="4.18.4" />
@@ -62,13 +62,13 @@
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" /> <PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" /> <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
<PackageVersion Include="prometheus-net" Version="8.2.1" /> <PackageVersion Include="prometheus-net" Version="8.2.1" />
<PackageVersion Include="Polly" Version="8.6.4" /> <PackageVersion Include="Polly" Version="8.6.5" />
<PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" /> <PackageVersion Include="Serilog.AspNetCore" Version="9.0.0" />
<PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" /> <PackageVersion Include="Serilog.Enrichers.Thread" Version="4.0.0" />
<PackageVersion Include="Serilog.Expressions" Version="5.0.0" /> <PackageVersion Include="Serilog.Expressions" Version="5.0.0" />
<PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" /> <PackageVersion Include="Serilog.Settings.Configuration" Version="9.0.0" />
<PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" /> <PackageVersion Include="Serilog.Sinks.Async" Version="2.1.0" />
<PackageVersion Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageVersion Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" /> <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
@@ -84,11 +84,11 @@
<PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" />
<PackageVersion Include="System.Globalization" Version="4.3.0" /> <PackageVersion Include="System.Globalization" Version="4.3.0" />
<PackageVersion Include="System.Linq.Async" Version="6.0.3" /> <PackageVersion Include="System.Linq.Async" Version="6.0.3" />
<PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.10" /> <PackageVersion Include="System.Text.Encoding.CodePages" Version="9.0.11" />
<PackageVersion Include="System.Text.Json" Version="9.0.10" /> <PackageVersion Include="System.Text.Json" Version="9.0.11" />
<PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.10" /> <PackageVersion Include="System.Threading.Tasks.Dataflow" Version="9.0.11" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" /> <PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="z440.atl.core" Version="7.5.0" /> <PackageVersion Include="z440.atl.core" Version="7.9.0" />
<PackageVersion Include="TMDbLib" Version="2.3.0" /> <PackageVersion Include="TMDbLib" Version="2.3.0" />
<PackageVersion Include="UTF.Unknown" Version="2.6.0" /> <PackageVersion Include="UTF.Unknown" Version="2.6.0" />
<PackageVersion Include="Xunit.Priority" Version="1.1.6" /> <PackageVersion Include="Xunit.Priority" Version="1.1.6" />

View File

@@ -36,7 +36,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Naming</PackageId> <PackageId>Jellyfin.Naming</PackageId>
<VersionPrefix>10.11.0</VersionPrefix> <VersionPrefix>10.11.7</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>

View File

@@ -10,12 +10,17 @@ namespace Emby.Naming.TV
/// </summary> /// </summary>
public static partial class SeasonPathParser public static partial class SeasonPathParser
{ {
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[]*|[]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$")] private static readonly Regex CleanNameRegex = new(@"[ ._\-\[\]]", RegexOptions.Compiled);
[GeneratedRegex(@"^\s*((?<seasonnumber>(?>\d+))(?:st|nd|rd|th|\.)*(?!\s*[Ee]\d+))\s*(?:[[]*|[]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPre(); private static partial Regex ProcessPre();
[GeneratedRegex(@"^\s*(?:[[]*|[]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>(?>\d+)(?!\s*[Ee]\d+))(?<rightpart>.*)$")] [GeneratedRegex(@"^\s*(?:[[]*|[]*|[sS](?:eason|æson|aison|taffel|eries|tagione|äsong|eizoen|easong|ezon|ezona|ezóna|ezonul)*|[tT](?:emporada)*|[kK](?:ausi)*|[Сс](?:езон)*)\s*(?<seasonnumber>\d+?)(?=\d{3,4}p|[^\d]|$)(?!\s*[Ee]\d)(?<rightpart>.*)$", RegexOptions.IgnoreCase)]
private static partial Regex ProcessPost(); private static partial Regex ProcessPost();
[GeneratedRegex(@"[sS](\d{1,4})(?!\d|[eE]\d)(?=\.|_|-|\[|\]|\s|$)", RegexOptions.None)]
private static partial Regex SeasonPrefix();
/// <summary> /// <summary>
/// Attempts to parse season number from path. /// Attempts to parse season number from path.
/// </summary> /// </summary>
@@ -56,45 +61,35 @@ namespace Emby.Naming.TV
bool supportSpecialAliases, bool supportSpecialAliases,
bool supportNumericSeasonFolders) bool supportNumericSeasonFolders)
{ {
string filename = Path.GetFileName(path); var fileName = Path.GetFileName(path);
filename = Regex.Replace(filename, "[ ._-]", string.Empty);
var seasonPrefixMatch = SeasonPrefix().Match(fileName);
if (seasonPrefixMatch.Success &&
int.TryParse(seasonPrefixMatch.Groups[1].Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
{
return (val, true);
}
string filename = CleanNameRegex.Replace(fileName, string.Empty);
if (parentFolderName is not null) if (parentFolderName is not null)
{ {
parentFolderName = Regex.Replace(parentFolderName, "[ ._-]", string.Empty); var cleanParent = CleanNameRegex.Replace(parentFolderName, string.Empty);
filename = filename.Replace(parentFolderName, string.Empty, StringComparison.OrdinalIgnoreCase); filename = filename.Replace(cleanParent, string.Empty, StringComparison.OrdinalIgnoreCase);
} }
if (supportSpecialAliases) if (supportSpecialAliases &&
{ (filename.Equals("specials", StringComparison.OrdinalIgnoreCase) ||
if (string.Equals(filename, "specials", StringComparison.OrdinalIgnoreCase)) filename.Equals("extras", StringComparison.OrdinalIgnoreCase)))
{ {
return (0, true); return (0, true);
} }
if (string.Equals(filename, "extras", StringComparison.OrdinalIgnoreCase)) if (supportNumericSeasonFolders &&
{ int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out val))
return (0, true);
}
}
if (supportNumericSeasonFolders)
{
if (int.TryParse(filename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
{ {
return (val, true); return (val, true);
} }
}
if (filename.StartsWith('s'))
{
var testFilename = filename.AsSpan()[1..];
if (int.TryParse(testFilename, NumberStyles.Integer, CultureInfo.InvariantCulture, out var val))
{
return (val, true);
}
}
var preMatch = ProcessPre().Match(filename); var preMatch = ProcessPre().Match(filename);
if (preMatch.Success) if (preMatch.Success)
@@ -113,9 +108,11 @@ namespace Emby.Naming.TV
var numberString = match.Groups["seasonnumber"]; var numberString = match.Groups["seasonnumber"];
if (numberString.Success) if (numberString.Success)
{ {
var seasonNumber = int.Parse(numberString.Value, CultureInfo.InvariantCulture); if (int.TryParse(numberString.Value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seasonNumber))
{
return (seasonNumber, true); return (seasonNumber, true);
} }
}
return (null, false); return (null, false);
} }

View File

@@ -107,10 +107,20 @@ namespace Emby.Server.Implementations.AppBase
private void CheckOrCreateMarker(string path, string markerName, bool recursive = false) private void CheckOrCreateMarker(string path, string markerName, bool recursive = false)
{ {
var otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => Path.GetFileName(e) != markerName); string? otherMarkers = null;
try
{
otherMarkers = GetMarkers(path, recursive).FirstOrDefault(e => !Path.GetFileName(e.AsSpan()).Equals(markerName, StringComparison.OrdinalIgnoreCase));
}
catch
{
// Error while checking for marker files, assume none exist and keep going
// TODO: add some logging
}
if (otherMarkers is not null) if (otherMarkers is not null)
{ {
throw new InvalidOperationException($"Exepected to find only {markerName} but found marker for {otherMarkers}."); throw new InvalidOperationException($"Expected to find only {markerName} but found marker for {otherMarkers}.");
} }
var markerPath = Path.Combine(path, markerName); var markerPath = Path.Combine(path, markerName);

View File

@@ -223,7 +223,7 @@ public class ChapterManager : IChapterManager
if (saveChapters && changesMade) if (saveChapters && changesMade)
{ {
_chapterRepository.SaveChapters(video.Id, chapters); SaveChapters(video, chapters);
} }
DeleteDeadImages(currentImages, chapters); DeleteDeadImages(currentImages, chapters);
@@ -234,7 +234,9 @@ public class ChapterManager : IChapterManager
/// <inheritdoc /> /// <inheritdoc />
public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters) public void SaveChapters(Video video, IReadOnlyList<ChapterInfo> chapters)
{ {
_chapterRepository.SaveChapters(video.Id, chapters); // Remove any chapters that are outside of the runtime of the video
var validChapters = chapters.Where(c => c.StartPositionTicks < video.RunTimeTicks).ToList();
_chapterRepository.SaveChapters(video.Id, validChapters);
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -1051,16 +1051,16 @@ namespace Emby.Server.Implementations.Dto
// Include artists that are not in the database yet, e.g., just added via metadata editor // Include artists that are not in the database yet, e.g., just added via metadata editor
// var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList(); // var foundArtists = artistItems.Items.Select(i => i.Item1.Name).ToList();
dto.ArtistItems = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]) var artistsLookup = _libraryManager.GetArtists([.. hasArtist.Artists.Where(e => !string.IsNullOrWhiteSpace(e))]);
.Where(e => e.Value.Length > 0)
.Select(i => dto.ArtistItems = hasArtist.Artists
{ .Where(name => !string.IsNullOrWhiteSpace(name))
return new NameGuidPair .Distinct()
{ .Select(name => artistsLookup.TryGetValue(name, out var artists) && artists.Length > 0
Name = i.Key, ? new NameGuidPair { Name = name, Id = artists[0].Id }
Id = i.Value.First().Id : null)
}; .Where(item => item is not null)
}).Where(i => i is not null).ToArray(); .ToArray();
} }
if (item is IHasAlbumArtist hasAlbumArtist) if (item is IHasAlbumArtist hasAlbumArtist)
@@ -1085,31 +1085,16 @@ namespace Emby.Server.Implementations.Dto
// }) // })
// .ToList(); // .ToList();
var albumArtistsLookup = _libraryManager.GetArtists([.. hasAlbumArtist.AlbumArtists.Where(e => !string.IsNullOrWhiteSpace(e))]);
dto.AlbumArtists = hasAlbumArtist.AlbumArtists dto.AlbumArtists = hasAlbumArtist.AlbumArtists
// .Except(foundArtists, new DistinctNameComparer()) .Where(name => !string.IsNullOrWhiteSpace(name))
.Select(i => .Distinct()
{ .Select(name => albumArtistsLookup.TryGetValue(name, out var albumArtists) && albumArtists.Length > 0
// This should not be necessary but we're seeing some cases of it ? new NameGuidPair { Name = name, Id = albumArtists[0].Id }
if (string.IsNullOrEmpty(i)) : null)
{ .Where(item => item is not null)
return null; .ToArray();
}
var artist = _libraryManager.GetArtist(i, new DtoOptions(false)
{
EnableImages = false
});
if (artist is not null)
{
return new NameGuidPair
{
Name = artist.Name,
Id = artist.Id
};
}
return null;
}).Where(i => i is not null).ToArray();
} }
// Add video info // Add video info

View File

@@ -352,6 +352,12 @@ namespace Emby.Server.Implementations.IO
return; return;
} }
var fileInfo = _fileSystem.GetFileSystemInfo(path);
if (DotIgnoreIgnoreRule.IsIgnored(fileInfo, null))
{
return;
}
// Ignore certain files, If the parent of an ignored path has a change event, ignore that too // Ignore certain files, If the parent of an ignored path has a change event, ignore that too
foreach (var i in _tempIgnoredPaths.Keys) foreach (var i in _tempIgnoredPaths.Keys)
{ {

View File

@@ -6,6 +6,7 @@ using System.Linq;
using System.Security; using System.Security;
using Jellyfin.Extensions; using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.IO;
using MediaBrowser.Model.IO; using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -152,6 +153,10 @@ namespace Emby.Server.Implementations.IO
/// <inheritdoc /> /// <inheritdoc />
public void MoveDirectory(string source, string destination) public void MoveDirectory(string source, string destination)
{ {
// Make sure parent directory of target exists
var parent = Directory.GetParent(destination);
parent?.Create();
try try
{ {
Directory.Move(source, destination); Directory.Move(source, destination);
@@ -248,48 +253,41 @@ namespace Emby.Server.Implementations.IO
{ {
result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory; result.IsDirectory = info is DirectoryInfo || (info.Attributes & FileAttributes.Directory) == FileAttributes.Directory;
// if (!result.IsDirectory)
// {
// result.IsHidden = (info.Attributes & FileAttributes.Hidden) == FileAttributes.Hidden;
// }
if (info is FileInfo fileInfo) if (info is FileInfo fileInfo)
{ {
result.Length = fileInfo.Length; result.CreationTimeUtc = GetCreationTimeUtc(info);
result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
// Issue #2354 get the size of files behind symbolic links. Also Enum.HasFlag is bad as it boxes! if (fileInfo.LinkTarget is not null)
if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
{ {
try try
{ {
using (var fileHandle = File.OpenHandle(fileInfo.FullName, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) var targetFileInfo = FileSystemHelper.ResolveLinkTarget(fileInfo, returnFinalTarget: true);
if (targetFileInfo is not null)
{ {
result.Length = RandomAccess.GetLength(fileHandle); result.Exists = targetFileInfo.Exists;
if (result.Exists)
{
result.Length = targetFileInfo.Length;
result.CreationTimeUtc = GetCreationTimeUtc(targetFileInfo);
result.LastWriteTimeUtc = GetLastWriteTimeUtc(targetFileInfo);
} }
} }
catch (FileNotFoundException ex) else
{ {
// Dangling symlinks cannot be detected before opening the file unfortunately...
_logger.LogError(ex, "Reading the file size of the symlink at {Path} failed. Marking the file as not existing.", fileInfo.FullName);
result.Exists = false; result.Exists = false;
} }
}
catch (UnauthorizedAccessException ex) catch (UnauthorizedAccessException ex)
{ {
_logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName); _logger.LogError(ex, "Reading the file at {Path} failed due to a permissions exception.", fileInfo.FullName);
} }
catch (IOException ex) }
else
{ {
// IOException generally means the file is not accessible due to filesystem issues result.Length = fileInfo.Length;
// Catch this exception and mark the file as not exist to ignore it
_logger.LogError(ex, "Reading the file at {Path} failed due to an IO Exception. Marking the file as not existing", fileInfo.FullName);
result.Exists = false;
} }
} }
} }
result.CreationTimeUtc = GetCreationTimeUtc(info);
result.LastWriteTimeUtc = GetLastWriteTimeUtc(info);
}
else else
{ {
result.IsDirectory = info is DirectoryInfo; result.IsDirectory = info is DirectoryInfo;
@@ -499,8 +497,17 @@ namespace Emby.Server.Implementations.IO
/// <inheritdoc /> /// <inheritdoc />
public virtual bool AreEqual(string path1, string path2) public virtual bool AreEqual(string path1, string path2)
{ {
return Path.TrimEndingDirectorySeparator(path1).Equals( if (string.IsNullOrWhiteSpace(path1) || string.IsNullOrWhiteSpace(path2))
Path.TrimEndingDirectorySeparator(path2), {
return false;
}
var normalized1 = Path.TrimEndingDirectorySeparator(path1);
var normalized2 = Path.TrimEndingDirectorySeparator(path2);
return string.Equals(
normalized1,
normalized2,
_isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal);
} }

View File

@@ -267,8 +267,11 @@ namespace Emby.Server.Implementations.Images
{ {
var image = item.GetImageInfo(type, 0); var image = item.GetImageInfo(type, 0);
if (image is not null) if (image is null)
{ {
return GetItemsWithImages(item).Count is not 0;
}
if (!image.IsLocalFile) if (!image.IsLocalFile)
{ {
return false; return false;
@@ -283,7 +286,6 @@ namespace Emby.Server.Implementations.Images
{ {
return false; return false;
} }
}
return true; return true;
} }

View File

@@ -98,5 +98,11 @@ namespace Emby.Server.Implementations.Images
return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex); return base.CreateImage(item, itemsWithImages, outputPath, imageType, imageIndex);
} }
protected override bool HasChangedByDate(BaseItem item, ItemImageInfo image)
{
var age = DateTime.UtcNow - image.DateModified;
return age.TotalDays > 7;
}
} }
} }

View File

@@ -1,6 +1,8 @@
using System; using System;
using System.IO; using System.IO;
using System.Text.RegularExpressions;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Resolvers; using MediaBrowser.Controller.Resolvers;
using MediaBrowser.Model.IO; using MediaBrowser.Model.IO;
@@ -11,28 +13,24 @@ namespace Emby.Server.Implementations.Library;
/// </summary> /// </summary>
public class DotIgnoreIgnoreRule : IResolverIgnoreRule public class DotIgnoreIgnoreRule : IResolverIgnoreRule
{ {
private static readonly bool IsWindows = OperatingSystem.IsWindows();
private static FileInfo? FindIgnoreFile(DirectoryInfo directory) private static FileInfo? FindIgnoreFile(DirectoryInfo directory)
{ {
var ignoreFile = new FileInfo(Path.Join(directory.FullName, ".ignore")); for (var current = directory; current is not null; current = current.Parent)
if (ignoreFile.Exists)
{ {
return ignoreFile; var ignorePath = Path.Join(current.FullName, ".ignore");
if (File.Exists(ignorePath))
{
return new FileInfo(ignorePath);
}
} }
var parentDir = directory.Parent;
if (parentDir is null)
{
return null; return null;
} }
return FindIgnoreFile(parentDir);
}
/// <inheritdoc /> /// <inheritdoc />
public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) public bool ShouldIgnore(FileSystemMetadata fileInfo, BaseItem? parent) => IsIgnored(fileInfo, parent);
{
return IsIgnored(fileInfo, parent);
}
/// <summary> /// <summary>
/// Checks whether or not the file is ignored. /// Checks whether or not the file is ignored.
@@ -42,60 +40,101 @@ public class DotIgnoreIgnoreRule : IResolverIgnoreRule
/// <returns>True if the file should be ignored.</returns> /// <returns>True if the file should be ignored.</returns>
public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent) public static bool IsIgnored(FileSystemMetadata fileInfo, BaseItem? parent)
{ {
if (fileInfo.IsDirectory) var searchDirectory = fileInfo.IsDirectory
{ ? new DirectoryInfo(fileInfo.FullName)
var dirIgnoreFile = FindIgnoreFile(new DirectoryInfo(fileInfo.FullName)); : new DirectoryInfo(Path.GetDirectoryName(fileInfo.FullName) ?? string.Empty);
if (dirIgnoreFile is null)
if (string.IsNullOrEmpty(searchDirectory.FullName))
{ {
return false; return false;
} }
// Fast path in case the ignore files isn't a symlink and is empty var ignoreFile = FindIgnoreFile(searchDirectory);
if ((dirIgnoreFile.Attributes & FileAttributes.ReparsePoint) == 0
&& dirIgnoreFile.Length == 0)
{
return true;
}
// ignore the directory only if the .ignore file is empty
// evaluate individual files otherwise
return string.IsNullOrWhiteSpace(GetFileContent(dirIgnoreFile));
}
var parentDirPath = Path.GetDirectoryName(fileInfo.FullName);
if (string.IsNullOrEmpty(parentDirPath))
{
return false;
}
var folder = new DirectoryInfo(parentDirPath);
var ignoreFile = FindIgnoreFile(folder);
if (ignoreFile is null) if (ignoreFile is null)
{ {
return false; return false;
} }
string ignoreFileString = GetFileContent(ignoreFile); // Fast path in case the ignore files isn't a symlink and is empty
if (ignoreFile.LinkTarget is null && ignoreFile.Length == 0)
if (string.IsNullOrWhiteSpace(ignoreFileString))
{ {
// Ignore directory if we just have the file // Ignore directory if we just have the file
return true; return true;
} }
var content = GetFileContent(ignoreFile);
return string.IsNullOrWhiteSpace(content)
|| CheckIgnoreRules(fileInfo.FullName, content, fileInfo.IsDirectory);
}
private static bool CheckIgnoreRules(string path, string ignoreFileContent, bool isDirectory)
{
// If file has content, base ignoring off the content .gitignore-style rules // If file has content, base ignoring off the content .gitignore-style rules
var ignoreRules = ignoreFileString.Split('\n', StringSplitOptions.RemoveEmptyEntries); var rules = ignoreFileContent.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return CheckIgnoreRules(path, rules, isDirectory);
}
/// <summary>
/// Checks whether a path should be ignored based on an array of ignore rules.
/// </summary>
/// <param name="path">The path to check.</param>
/// <param name="rules">The array of ignore rules.</param>
/// <param name="isDirectory">Whether the path is a directory.</param>
/// <returns>True if the path should be ignored.</returns>
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory)
=> CheckIgnoreRules(path, rules, isDirectory, IsWindows);
/// <summary>
/// Checks whether a path should be ignored based on an array of ignore rules.
/// </summary>
/// <param name="path">The path to check.</param>
/// <param name="rules">The array of ignore rules.</param>
/// <param name="isDirectory">Whether the path is a directory.</param>
/// <param name="normalizePath">Whether to normalize backslashes to forward slashes (for Windows paths).</param>
/// <returns>True if the path should be ignored.</returns>
internal static bool CheckIgnoreRules(string path, string[] rules, bool isDirectory, bool normalizePath)
{
var ignore = new Ignore.Ignore(); var ignore = new Ignore.Ignore();
ignore.Add(ignoreRules);
return ignore.IsIgnored(fileInfo.FullName); // Add each rule individually to catch and skip invalid patterns
var validRulesAdded = 0;
foreach (var rule in rules)
{
try
{
ignore.Add(rule);
validRulesAdded++;
}
catch (RegexParseException)
{
// Ignore invalid patterns
}
} }
private static string GetFileContent(FileInfo dirIgnoreFile) // If no valid rules were added, fall back to ignoring everything (like an empty .ignore file)
if (validRulesAdded == 0)
{ {
using (var reader = dirIgnoreFile.OpenText()) return true;
}
// Mitigate the problem of the Ignore library not handling Windows paths correctly.
// See https://github.com/jellyfin/jellyfin/issues/15484
var pathToCheck = normalizePath ? path.NormalizePath('/') : path;
// Add trailing slash for directories to match "folder/"
if (isDirectory)
{ {
return reader.ReadToEnd(); pathToCheck = string.Concat(pathToCheck.AsSpan().TrimEnd('/'), "/");
} }
return ignore.IsIgnored(pathToCheck);
}
private static string GetFileContent(FileInfo ignoreFile)
{
ignoreFile = FileSystemHelper.ResolveLinkTarget(ignoreFile, returnFinalTarget: true) ?? ignoreFile;
return ignoreFile.Exists
? File.ReadAllText(ignoreFile.FullName)
: string.Empty;
} }
} }

View File

@@ -50,6 +50,10 @@ namespace Emby.Server.Implementations.Library
"**/lost+found", "**/lost+found",
"**/subs/**", "**/subs/**",
"**/subs", "**/subs",
"**/.snapshots/**",
"**/.snapshots",
"**/.snapshot/**",
"**/.snapshot",
// Trickplay files // Trickplay files
"**/*.trickplay", "**/*.trickplay",

View File

@@ -457,6 +457,12 @@ namespace Emby.Server.Implementations.Library
_cache.TryRemove(child.Id, out _); _cache.TryRemove(child.Id, out _);
} }
if (parent is Folder folder)
{
folder.Children = null;
folder.UserData = null;
}
ReportItemRemoved(item, parent); ReportItemRemoved(item, parent);
} }
@@ -1052,6 +1058,7 @@ namespace Emby.Server.Implementations.Library
{ {
IncludeItemTypes = [BaseItemKind.MusicArtist], IncludeItemTypes = [BaseItemKind.MusicArtist],
Name = name, Name = name,
UseRawName = true,
DtoOptions = options DtoOptions = options
}).Cast<MusicArtist>() }).Cast<MusicArtist>()
.OrderBy(i => i.IsAccessedByName ? 1 : 0) .OrderBy(i => i.IsAccessedByName ? 1 : 0)
@@ -1993,6 +2000,12 @@ namespace Emby.Server.Implementations.Library
RegisterItem(item); RegisterItem(item);
} }
if (parent is Folder folder)
{
folder.Children = null;
folder.UserData = null;
}
if (ItemAdded is not null) if (ItemAdded is not null)
{ {
foreach (var item in items) foreach (var item in items)
@@ -2150,6 +2163,12 @@ namespace Emby.Server.Implementations.Library
_itemRepository.SaveItems(items, cancellationToken); _itemRepository.SaveItems(items, cancellationToken);
if (parent is Folder folder)
{
folder.Children = null;
folder.UserData = null;
}
if (ItemUpdated is not null) if (ItemUpdated is not null)
{ {
foreach (var item in items) foreach (var item in items)
@@ -2183,6 +2202,12 @@ namespace Emby.Server.Implementations.Library
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken) public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken); => UpdateItemsAsync([item], parent, updateReason, cancellationToken);
/// <inheritdoc />
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
{
await _itemRepository.ReattachUserDataAsync(item, cancellationToken).ConfigureAwait(false);
}
public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason) public async Task RunMetadataSavers(BaseItem item, ItemUpdateType updateReason)
{ {
if (item.IsFileProtocol) if (item.IsFileProtocol)
@@ -3176,19 +3201,7 @@ namespace Emby.Server.Implementations.Library
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath; var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName); var virtualFolderPath = Path.Combine(rootFolderPath, virtualFolderName);
var shortcutFilename = Path.GetFileNameWithoutExtension(path); CreateShortcut(virtualFolderPath, pathInfo);
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
while (File.Exists(lnk))
{
shortcutFilename += "1";
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
}
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
RemoveContentTypeOverrides(path);
if (saveLibraryOptions) if (saveLibraryOptions)
{ {
@@ -3353,5 +3366,24 @@ namespace Emby.Server.Implementations.Library
return item is UserRootFolder || item.IsVisibleStandalone(user); return item is UserRootFolder || item.IsVisibleStandalone(user);
} }
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo)
{
var path = pathInfo.Path;
var rootFolderPath = _configurationManager.ApplicationPaths.DefaultUserViewsPath;
var shortcutFilename = Path.GetFileNameWithoutExtension(path);
var lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
while (File.Exists(lnk))
{
shortcutFilename += "1";
lnk = Path.Combine(virtualFolderPath, shortcutFilename + ShortcutFileExtension);
}
_fileSystem.CreateShortcut(lnk, _appHost.ReverseVirtualPath(path));
RemoveContentTypeOverrides(path);
}
} }
} }

View File

@@ -226,6 +226,11 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc />> /// <inheritdoc />>
public MediaProtocol GetPathProtocol(string path) public MediaProtocol GetPathProtocol(string path)
{ {
if (string.IsNullOrEmpty(path))
{
return MediaProtocol.File;
}
if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase)) if (path.StartsWith("Rtsp", StringComparison.OrdinalIgnoreCase))
{ {
return MediaProtocol.Rtsp; return MediaProtocol.Rtsp;

View File

@@ -28,7 +28,9 @@ namespace Emby.Server.Implementations.Library
public IReadOnlyList<BaseItem> GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions) public IReadOnlyList<BaseItem> GetInstantMixFromSong(Audio item, User? user, DtoOptions dtoOptions)
{ {
return GetInstantMixFromGenres(item.Genres, user, dtoOptions); var instantMixItems = GetInstantMixFromGenres(item.Genres, user, dtoOptions);
return [item, .. instantMixItems.Where(i => !i.Id.Equals(item.Id))];
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -369,13 +369,16 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
// We need to only look at the name of this actual item (not parents) // We need to only look at the name of this actual item (not parents)
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan()); var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
if (!justName.IsEmpty)
{
// Check for TMDb id
var tmdbid = justName.GetAttributeValue("tmdbid"); var tmdbid = justName.GetAttributeValue("tmdbid");
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
// If not in a mixed folder and ID not found in folder path, check filename
if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
{
tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
} }
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
if (!string.IsNullOrEmpty(item.Path)) if (!string.IsNullOrEmpty(item.Path))
{ {
// Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether id in parent dir or in file name) // Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether id in parent dir or in file name)

View File

@@ -38,6 +38,7 @@ namespace Emby.Server.Implementations.Localization
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
private readonly ConcurrentDictionary<string, CultureDto?> _cultureCache = new(StringComparer.OrdinalIgnoreCase);
private List<CultureDto> _cultures = []; private List<CultureDto> _cultures = [];
private FrozenDictionary<string, string> _iso6392BtoT = null!; private FrozenDictionary<string, string> _iso6392BtoT = null!;
@@ -161,6 +162,7 @@ namespace Emby.Server.Implementations.Localization
list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames)); list.Add(new CultureDto(name, displayname, twoCharName, threeLetterNames));
} }
_cultureCache.Clear();
_cultures = list; _cultures = list;
_iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase); _iso6392BtoT = iso6392BtoTdict.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
} }
@@ -169,20 +171,31 @@ namespace Emby.Server.Implementations.Localization
/// <inheritdoc /> /// <inheritdoc />
public CultureDto? FindLanguageInfo(string language) public CultureDto? FindLanguageInfo(string language)
{ {
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs if (string.IsNullOrEmpty(language))
for (var i = 0; i < _cultures.Count; i++)
{ {
var culture = _cultures[i]; return null;
if (language.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase) }
|| language.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|| culture.ThreeLetterISOLanguageNames.Contains(language, StringComparison.OrdinalIgnoreCase) return _cultureCache.GetOrAdd(
|| language.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase)) language,
static (lang, cultures) =>
{
// TODO language should ideally be a ReadOnlySpan but moq cannot mock ref structs
for (var i = 0; i < cultures.Count; i++)
{
var culture = cultures[i];
if (lang.Equals(culture.DisplayName, StringComparison.OrdinalIgnoreCase)
|| lang.Equals(culture.Name, StringComparison.OrdinalIgnoreCase)
|| culture.ThreeLetterISOLanguageNames.Contains(lang, StringComparison.OrdinalIgnoreCase)
|| lang.Equals(culture.TwoLetterISOLanguageName, StringComparison.OrdinalIgnoreCase))
{ {
return culture; return culture;
} }
} }
return default; return null;
},
_cultures);
} }
/// <inheritdoc /> /// <inheritdoc />
@@ -311,15 +324,19 @@ namespace Emby.Server.Implementations.Localization
else else
{ {
// Fall back to server default language for ratings check // Fall back to server default language for ratings check
// If it has no ratings, use the US ratings var ratingsDictionary = GetParentalRatingsDictionary();
var ratingsDictionary = GetParentalRatingsDictionary() ?? GetParentalRatingsDictionary("us");
if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value)) if (ratingsDictionary is not null && ratingsDictionary.TryGetValue(rating, out ParentalRatingScore? value))
{ {
return value; return value;
} }
} }
// If we don't find anything, check all ratings systems // If we don't find anything, check all ratings systems, starting with US
if (_allParentalRatings.TryGetValue("us", out var usRatings) && usRatings.TryGetValue(rating, out var usValue))
{
return usValue;
}
foreach (var dictionary in _allParentalRatings.Values) foreach (var dictionary in _allParentalRatings.Values)
{ {
if (dictionary.TryGetValue(rating, out var value)) if (dictionary.TryGetValue(rating, out var value))

View File

@@ -347,8 +347,8 @@ pli||pi|Pali|pali
pol||pl|Polish|polonais pol||pl|Polish|polonais
pon|||Pohnpeian|pohnpei pon|||Pohnpeian|pohnpei
por||pt|Portuguese|portugais por||pt|Portuguese|portugais
por||pt-pt|Portuguese (Portugal)|portugais (pt-pt) pop||pt-pt|Portuguese (Portugal)|portugais (pt-pt)
por||pt-br|Portuguese (Brazil)|portugais (pt-br) pob||pt-br|Portuguese (Brazil)|portugais (pt-br)
pra|||Prakrit languages|prâkrit, langues pra|||Prakrit languages|prâkrit, langues
pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500) pro|||Provençal, Old (to 1500)|provençal ancien (jusqu'à 1500)
pus||ps|Pushto; Pashto|pachto pus||ps|Pushto; Pashto|pachto

View File

@@ -244,6 +244,7 @@ namespace Emby.Server.Implementations.Playlists
// Update the playlist in the repository // Update the playlist in the repository
playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd]; playlist.LinkedChildren = [.. playlist.LinkedChildren, .. childrenToAdd];
playlist.DateLastMediaAdded = DateTime.UtcNow;
await UpdatePlaylistInternal(playlist).ConfigureAwait(false); await UpdatePlaylistInternal(playlist).ConfigureAwait(false);

View File

@@ -1175,7 +1175,8 @@ namespace Emby.Server.Implementations.Session
return session; return session;
} }
private SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo) /// <inheritdoc />
public SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo)
{ {
return new SessionInfoDto return new SessionInfoDto
{ {

View File

@@ -156,6 +156,11 @@ namespace Emby.Server.Implementations.Updates
_logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest); _logger.LogError(ex, "The URL configured for the plugin repository manifest URL is not valid: {Manifest}", manifest);
return Array.Empty<PackageInfo>(); return Array.Empty<PackageInfo>();
} }
catch (NotSupportedException ex)
{
_logger.LogError(ex, "The URL scheme configured for the plugin repository is not supported: {Manifest}", manifest);
return Array.Empty<PackageInfo>();
}
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
_logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest); _logger.LogError(ex, "An error occurred while accessing the plugin manifest: {Manifest}", manifest);
@@ -223,15 +228,14 @@ namespace Emby.Server.Implementations.Updates
Guid id = default, Guid id = default,
Version? specificVersion = null) Version? specificVersion = null)
{ {
if (name is not null)
{
availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
}
if (!id.IsEmpty()) if (!id.IsEmpty())
{ {
availablePackages = availablePackages.Where(x => x.Id.Equals(id)); availablePackages = availablePackages.Where(x => x.Id.Equals(id));
} }
else if (name is not null)
{
availablePackages = availablePackages.Where(x => x.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
}
if (specificVersion is not null) if (specificVersion is not null)
{ {

View File

@@ -92,18 +92,18 @@ public class AudioController : BaseJellyfinApiController
[ProducesAudioFile] [ProducesAudioFile]
public async Task<ActionResult> GetAudioStream( public async Task<ActionResult> GetAudioStream(
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
[FromQuery] bool? @static, [FromQuery] bool? @static,
[FromQuery] string? @params, [FromQuery] string? @params,
[FromQuery] string? tag, [FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId, [FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength, [FromQuery] int? segmentLength,
[FromQuery] int? minSegments, [FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId, [FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId, [FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy, [FromQuery] bool? allowAudioStreamCopy,
@@ -114,7 +114,7 @@ public class AudioController : BaseJellyfinApiController
[FromQuery] int? audioChannels, [FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels, [FromQuery] int? maxAudioChannels,
[FromQuery] string? profile, [FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate, [FromQuery] float? framerate,
[FromQuery] float? maxFramerate, [FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps, [FromQuery] bool? copyTimestamps,
@@ -133,8 +133,8 @@ public class AudioController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit, [FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId, [FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons, [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex, [FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex, [FromQuery] int? videoStreamIndex,
@@ -259,18 +259,18 @@ public class AudioController : BaseJellyfinApiController
[ProducesAudioFile] [ProducesAudioFile]
public async Task<ActionResult> GetAudioStreamByContainer( public async Task<ActionResult> GetAudioStreamByContainer(
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container, [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
[FromQuery] bool? @static, [FromQuery] bool? @static,
[FromQuery] string? @params, [FromQuery] string? @params,
[FromQuery] string? tag, [FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId, [FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength, [FromQuery] int? segmentLength,
[FromQuery] int? minSegments, [FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId, [FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId, [FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy, [FromQuery] bool? allowAudioStreamCopy,
@@ -281,7 +281,7 @@ public class AudioController : BaseJellyfinApiController
[FromQuery] int? audioChannels, [FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels, [FromQuery] int? maxAudioChannels,
[FromQuery] string? profile, [FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate, [FromQuery] float? framerate,
[FromQuery] float? maxFramerate, [FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps, [FromQuery] bool? copyTimestamps,
@@ -300,8 +300,8 @@ public class AudioController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit, [FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId, [FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons, [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex, [FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex, [FromQuery] int? videoStreamIndex,

View File

@@ -167,18 +167,18 @@ public class DynamicHlsController : BaseJellyfinApiController
[ProducesPlaylistFile] [ProducesPlaylistFile]
public async Task<ActionResult> GetLiveHlsStream( public async Task<ActionResult> GetLiveHlsStream(
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
[FromQuery] bool? @static, [FromQuery] bool? @static,
[FromQuery] string? @params, [FromQuery] string? @params,
[FromQuery] string? tag, [FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId, [FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength, [FromQuery] int? segmentLength,
[FromQuery] int? minSegments, [FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId, [FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId, [FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy, [FromQuery] bool? allowAudioStreamCopy,
@@ -189,7 +189,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels, [FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels, [FromQuery] int? maxAudioChannels,
[FromQuery] string? profile, [FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate, [FromQuery] float? framerate,
[FromQuery] float? maxFramerate, [FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps, [FromQuery] bool? copyTimestamps,
@@ -208,8 +208,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit, [FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId, [FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons, [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex, [FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex, [FromQuery] int? videoStreamIndex,
@@ -416,12 +416,12 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] string? tag, [FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId, [FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength, [FromQuery] int? segmentLength,
[FromQuery] int? minSegments, [FromQuery] int? minSegments,
[FromQuery, Required] string mediaSourceId, [FromQuery, Required] string mediaSourceId,
[FromQuery] string? deviceId, [FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy, [FromQuery] bool? allowAudioStreamCopy,
@@ -432,7 +432,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels, [FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels, [FromQuery] int? maxAudioChannels,
[FromQuery] string? profile, [FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate, [FromQuery] float? framerate,
[FromQuery] float? maxFramerate, [FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps, [FromQuery] bool? copyTimestamps,
@@ -453,8 +453,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit, [FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId, [FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons, [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex, [FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex, [FromQuery] int? videoStreamIndex,
@@ -592,12 +592,12 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] string? tag, [FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId, [FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength, [FromQuery] int? segmentLength,
[FromQuery] int? minSegments, [FromQuery] int? minSegments,
[FromQuery, Required] string mediaSourceId, [FromQuery, Required] string mediaSourceId,
[FromQuery] string? deviceId, [FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy, [FromQuery] bool? allowAudioStreamCopy,
@@ -609,7 +609,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels, [FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels, [FromQuery] int? maxAudioChannels,
[FromQuery] string? profile, [FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate, [FromQuery] float? framerate,
[FromQuery] float? maxFramerate, [FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps, [FromQuery] bool? copyTimestamps,
@@ -628,8 +628,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit, [FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId, [FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons, [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex, [FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex, [FromQuery] int? videoStreamIndex,
@@ -762,12 +762,12 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] string? tag, [FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId, [FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength, [FromQuery] int? segmentLength,
[FromQuery] int? minSegments, [FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId, [FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId, [FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy, [FromQuery] bool? allowAudioStreamCopy,
@@ -778,7 +778,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels, [FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels, [FromQuery] int? maxAudioChannels,
[FromQuery] string? profile, [FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate, [FromQuery] float? framerate,
[FromQuery] float? maxFramerate, [FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps, [FromQuery] bool? copyTimestamps,
@@ -799,8 +799,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit, [FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId, [FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons, [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex, [FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex, [FromQuery] int? videoStreamIndex,
@@ -934,12 +934,12 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] string? tag, [FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId, [FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength, [FromQuery] int? segmentLength,
[FromQuery] int? minSegments, [FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId, [FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId, [FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy, [FromQuery] bool? allowAudioStreamCopy,
@@ -951,7 +951,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels, [FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels, [FromQuery] int? maxAudioChannels,
[FromQuery] string? profile, [FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate, [FromQuery] float? framerate,
[FromQuery] float? maxFramerate, [FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps, [FromQuery] bool? copyTimestamps,
@@ -970,8 +970,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit, [FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId, [FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons, [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex, [FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex, [FromQuery] int? videoStreamIndex,
@@ -1107,7 +1107,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromRoute, Required] string playlistId, [FromRoute, Required] string playlistId,
[FromRoute, Required] int segmentId, [FromRoute, Required] int segmentId,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container, [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
[FromQuery, Required] long runtimeTicks, [FromQuery, Required] long runtimeTicks,
[FromQuery, Required] long actualSegmentLengthTicks, [FromQuery, Required] long actualSegmentLengthTicks,
[FromQuery] bool? @static, [FromQuery] bool? @static,
@@ -1115,12 +1115,12 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] string? tag, [FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId, [FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength, [FromQuery] int? segmentLength,
[FromQuery] int? minSegments, [FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId, [FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId, [FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy, [FromQuery] bool? allowAudioStreamCopy,
@@ -1131,7 +1131,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels, [FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels, [FromQuery] int? maxAudioChannels,
[FromQuery] string? profile, [FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate, [FromQuery] float? framerate,
[FromQuery] float? maxFramerate, [FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps, [FromQuery] bool? copyTimestamps,
@@ -1152,8 +1152,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit, [FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId, [FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons, [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex, [FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex, [FromQuery] int? videoStreamIndex,
@@ -1292,7 +1292,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromRoute, Required] string playlistId, [FromRoute, Required] string playlistId,
[FromRoute, Required] int segmentId, [FromRoute, Required] int segmentId,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container, [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
[FromQuery, Required] long runtimeTicks, [FromQuery, Required] long runtimeTicks,
[FromQuery, Required] long actualSegmentLengthTicks, [FromQuery, Required] long actualSegmentLengthTicks,
[FromQuery] bool? @static, [FromQuery] bool? @static,
@@ -1300,12 +1300,12 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] string? tag, [FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId, [FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength, [FromQuery] int? segmentLength,
[FromQuery] int? minSegments, [FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId, [FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId, [FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy, [FromQuery] bool? allowAudioStreamCopy,
@@ -1317,7 +1317,7 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? audioChannels, [FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels, [FromQuery] int? maxAudioChannels,
[FromQuery] string? profile, [FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate, [FromQuery] float? framerate,
[FromQuery] float? maxFramerate, [FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps, [FromQuery] bool? copyTimestamps,
@@ -1336,8 +1336,8 @@ public class DynamicHlsController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit, [FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId, [FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons, [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex, [FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex, [FromQuery] int? videoStreamIndex,
@@ -1421,10 +1421,20 @@ public class DynamicHlsController : BaseJellyfinApiController
cancellationTokenSource.Token) cancellationTokenSource.Token)
.ConfigureAwait(false); .ConfigureAwait(false);
var mediaSourceId = state.BaseRequest.MediaSourceId; var mediaSourceId = state.BaseRequest.MediaSourceId;
double fps = state.TargetFramerate ?? 0.0f;
int segmentLength = state.SegmentLength * 1000;
// If video is transcoded and framerate is fractional (i.e. 23.976), we need to slightly adjust segment length
if (!EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && Math.Abs(fps - Math.Floor(fps + 0.001f)) > 0.001)
{
double nearestIntFramerate = Math.Ceiling(fps);
segmentLength = (int)Math.Ceiling(segmentLength * (nearestIntFramerate / fps));
}
var request = new CreateMainPlaylistRequest( var request = new CreateMainPlaylistRequest(
mediaSourceId is null ? null : Guid.Parse(mediaSourceId), mediaSourceId is null ? null : Guid.Parse(mediaSourceId),
state.MediaPath, state.MediaPath,
state.SegmentLength * 1000, segmentLength,
state.RunTimeTicks ?? 0, state.RunTimeTicks ?? 0,
state.Request.SegmentContainer ?? string.Empty, state.Request.SegmentContainer ?? string.Empty,
"hls1/main/", "hls1/main/",
@@ -1625,8 +1635,11 @@ public class DynamicHlsController : BaseJellyfinApiController
var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions; var useLegacySegmentOption = _mediaEncoder.EncoderVersion < _minFFmpegHlsSegmentOptions;
if (state.VideoStream is not null && state.IsOutputVideo)
{
// fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT // fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont"; hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
}
segmentFormat = "fmp4" + outputFmp4HeaderArg; segmentFormat = "fmp4" + outputFmp4HeaderArg;
} }
@@ -1836,8 +1849,9 @@ public class DynamicHlsController : BaseJellyfinApiController
{ {
if (isActualOutputVideoCodecHevc) if (isActualOutputVideoCodecHevc)
{ {
// Prefer dvh1 to dvhe // Use hvc1 for 8.4. This is what Dolby uses for its official sample streams. Tagging with dvh1 would break some players with strict tag checking like Apple Safari.
args += " -tag:v:0 dvh1 -strict -2"; var codecTag = state.VideoStream.VideoRangeType == VideoRangeType.DOVIWithHLG ? "hvc1" : "dvh1";
args += $" -tag:v:0 {codecTag} -strict -2";
} }
else if (isActualOutputVideoCodecAv1) else if (isActualOutputVideoCodecAv1)
{ {

View File

@@ -418,7 +418,7 @@ public class ItemUpdateController : BaseJellyfinApiController
{ {
if (item is IHasAlbumArtist hasAlbumArtists) if (item is IHasAlbumArtist hasAlbumArtists)
{ {
hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name); hasAlbumArtists.AlbumArtists = Array.ConvertAll(request.AlbumArtists, i => i.Name.Trim());
} }
} }
@@ -426,7 +426,7 @@ public class ItemUpdateController : BaseJellyfinApiController
{ {
if (item is IHasArtist hasArtists) if (item is IHasArtist hasArtists)
{ {
hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name); hasArtists.Artists = Array.ConvertAll(request.ArtistItems, i => i.Name.Trim());
} }
} }

View File

@@ -23,6 +23,7 @@ using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers; using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Activity; using MediaBrowser.Model.Activity;
@@ -700,7 +701,18 @@ public class LibraryController : BaseJellyfinApiController
// Quotes are valid in linux. They'll possibly cause issues here. // Quotes are valid in linux. They'll possibly cause issues here.
var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal); var filename = Path.GetFileName(item.Path)?.Replace("\"", string.Empty, StringComparison.Ordinal);
return PhysicalFile(item.Path, MimeTypes.GetMimeType(item.Path), filename, true); var filePath = item.Path;
if (item.IsFileProtocol)
{
// PhysicalFile does not work well with symlinks at the moment.
var resolved = FileSystemHelper.ResolveLinkTarget(filePath, returnFinalTarget: true);
if (resolved is not null && resolved.Exists)
{
filePath = resolved.FullName;
}
}
return PhysicalFile(filePath, MimeTypes.GetMimeType(filePath), filename, true);
} }
/// <summary> /// <summary>

View File

@@ -342,6 +342,17 @@ public class LibraryStructureController : BaseJellyfinApiController
return NotFound(); return NotFound();
} }
LibraryOptions options = item.GetLibraryOptions();
foreach (var mediaPath in request.LibraryOptions!.PathInfos)
{
if (options.PathInfos.Any(i => i.Path == mediaPath.Path))
{
continue;
}
_libraryManager.CreateShortcut(item.Path, mediaPath);
}
item.UpdateLibraryOptions(request.LibraryOptions); item.UpdateLibraryOptions(request.LibraryOptions);
return NoContent(); return NoContent();
} }

View File

@@ -458,7 +458,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <returns>A <see cref="NoContentResult"/>.</returns> /// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpPost("Tuners/{tunerId}/Reset")] [HttpPost("Tuners/{tunerId}/Reset")]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
[Authorize(Policy = Policies.LiveTvManagement)] [Authorize(Policy = Policies.RequiresElevation)]
public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId) public async Task<ActionResult> ResetTuner([FromRoute, Required] string tunerId)
{ {
await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false); await _liveTvManager.ResetTuner(tunerId, CancellationToken.None).ConfigureAwait(false);
@@ -983,7 +983,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="200">Created tuner host returned.</response> /// <response code="200">Created tuner host returned.</response>
/// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns> /// <returns>A <see cref="OkResult"/> containing the created tuner host.</returns>
[HttpPost("TunerHosts")] [HttpPost("TunerHosts")]
[Authorize(Policy = Policies.LiveTvManagement)] [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo) public async Task<ActionResult<TunerHostInfo>> AddTunerHost([FromBody] TunerHostInfo tunerHostInfo)
=> await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false); => await _tunerHostManager.SaveTunerHost(tunerHostInfo).ConfigureAwait(false);
@@ -995,7 +995,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="204">Tuner host deleted.</response> /// <response code="204">Tuner host deleted.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns> /// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpDelete("TunerHosts")] [HttpDelete("TunerHosts")]
[Authorize(Policy = Policies.LiveTvManagement)] [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult DeleteTunerHost([FromQuery] string? id) public ActionResult DeleteTunerHost([FromQuery] string? id)
{ {
@@ -1028,7 +1028,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="200">Created listings provider returned.</response> /// <response code="200">Created listings provider returned.</response>
/// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns> /// <returns>A <see cref="OkResult"/> containing the created listings provider.</returns>
[HttpPost("ListingProviders")] [HttpPost("ListingProviders")]
[Authorize(Policy = Policies.LiveTvManagement)] [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")] [SuppressMessage("Microsoft.Performance", "CA5350:RemoveSha1", MessageId = "AddListingProvider", Justification = "Imported from ServiceStack")]
public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider( public async Task<ActionResult<ListingsProviderInfo>> AddListingProvider(
@@ -1054,7 +1054,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="204">Listing provider deleted.</response> /// <response code="204">Listing provider deleted.</response>
/// <returns>A <see cref="NoContentResult"/>.</returns> /// <returns>A <see cref="NoContentResult"/>.</returns>
[HttpDelete("ListingProviders")] [HttpDelete("ListingProviders")]
[Authorize(Policy = Policies.LiveTvManagement)] [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status204NoContent)]
public ActionResult DeleteListingProvider([FromQuery] string? id) public ActionResult DeleteListingProvider([FromQuery] string? id)
{ {
@@ -1087,7 +1087,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="200">Available countries returned.</response> /// <response code="200">Available countries returned.</response>
/// <returns>A <see cref="FileResult"/> containing the available countries.</returns> /// <returns>A <see cref="FileResult"/> containing the available countries.</returns>
[HttpGet("ListingProviders/SchedulesDirect/Countries")] [HttpGet("ListingProviders/SchedulesDirect/Countries")]
[Authorize(Policy = Policies.LiveTvAccess)] [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
[ProducesFile(MediaTypeNames.Application.Json)] [ProducesFile(MediaTypeNames.Application.Json)]
public async Task<ActionResult> GetSchedulesDirectCountries() public async Task<ActionResult> GetSchedulesDirectCountries()
@@ -1108,7 +1108,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="200">Channel mapping options returned.</response> /// <response code="200">Channel mapping options returned.</response>
/// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns> /// <returns>An <see cref="OkResult"/> containing the channel mapping options.</returns>
[HttpGet("ChannelMappingOptions")] [HttpGet("ChannelMappingOptions")]
[Authorize(Policy = Policies.LiveTvAccess)] [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId) public Task<ChannelMappingOptionsDto> GetChannelMappingOptions([FromQuery] string? providerId)
=> _listingsManager.GetChannelMappingOptions(providerId); => _listingsManager.GetChannelMappingOptions(providerId);
@@ -1120,7 +1120,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <response code="200">Created channel mapping returned.</response> /// <response code="200">Created channel mapping returned.</response>
/// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns> /// <returns>An <see cref="OkResult"/> containing the created channel mapping.</returns>
[HttpPost("ChannelMappings")] [HttpPost("ChannelMappings")]
[Authorize(Policy = Policies.LiveTvManagement)] [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto) public Task<TunerChannelMapping> SetChannelMapping([FromBody, Required] SetChannelMappingDto dto)
=> _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId); => _listingsManager.SetChannelMapping(dto.ProviderId, dto.TunerChannelId, dto.ProviderChannelId);
@@ -1144,7 +1144,7 @@ public class LiveTvController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the tuners.</returns> /// <returns>An <see cref="OkResult"/> containing the tuners.</returns>
[HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")] [HttpGet("Tuners/Discvover", Name = "DiscvoverTuners")]
[HttpGet("Tuners/Discover")] [HttpGet("Tuners/Discover")]
[Authorize(Policy = Policies.LiveTvManagement)] [Authorize(Policy = Policies.RequiresElevation)]
[ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status200OK)]
public IAsyncEnumerable<TunerHostInfo> DiscoverTuners([FromQuery] bool newDevicesOnly = false) public IAsyncEnumerable<TunerHostInfo> DiscoverTuners([FromQuery] bool newDevicesOnly = false)
=> _tunerHostManager.DiscoverTuners(newDevicesOnly); => _tunerHostManager.DiscoverTuners(newDevicesOnly);
@@ -1192,7 +1192,7 @@ public class LiveTvController : BaseJellyfinApiController
[ProducesVideoFile] [ProducesVideoFile]
public ActionResult GetLiveStreamFile( public ActionResult GetLiveStreamFile(
[FromRoute, Required] string streamId, [FromRoute, Required] string streamId,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container) [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container)
{ {
var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId); var liveStreamInfo = _mediaSourceManager.GetLiveStreamInfoByUniqueId(streamId);
if (liveStreamInfo is null) if (liveStreamInfo is null)

View File

@@ -58,7 +58,7 @@ public class SyncPlayController : BaseJellyfinApiController
[FromBody, Required] NewGroupRequestDto requestData) [FromBody, Required] NewGroupRequestDto requestData)
{ {
var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false); var currentSession = await RequestHelpers.GetSession(_sessionManager, _userManager, HttpContext).ConfigureAwait(false);
var syncPlayRequest = new NewGroupRequest(requestData.GroupName); var syncPlayRequest = new NewGroupRequest(requestData.GroupName.Trim());
return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None)); return Ok(_syncPlayManager.NewGroup(currentSession, syncPlayRequest, CancellationToken.None));
} }

View File

@@ -86,7 +86,7 @@ public class TrickplayController : BaseJellyfinApiController
[FromRoute, Required] int index, [FromRoute, Required] int index,
[FromQuery] Guid? mediaSourceId) [FromQuery] Guid? mediaSourceId)
{ {
var item = _libraryManager.GetItemById<BaseItem>(itemId, User.GetUserId()); var item = _libraryManager.GetItemById<BaseItem>(mediaSourceId ?? itemId, User.GetUserId());
if (item is null) if (item is null)
{ {
return NotFound(); return NotFound();

View File

@@ -102,13 +102,13 @@ public class UniversalAudioController : BaseJellyfinApiController
[FromQuery] string? mediaSourceId, [FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId, [FromQuery] string? deviceId,
[FromQuery] Guid? userId, [FromQuery] Guid? userId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] int? maxAudioChannels, [FromQuery] int? maxAudioChannels,
[FromQuery] int? transcodingAudioChannels, [FromQuery] int? transcodingAudioChannels,
[FromQuery] int? maxStreamingBitrate, [FromQuery] int? maxStreamingBitrate,
[FromQuery] int? audioBitRate, [FromQuery] int? audioBitRate,
[FromQuery] long? startTimeTicks, [FromQuery] long? startTimeTicks,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? transcodingContainer, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? transcodingContainer,
[FromQuery] MediaStreamProtocol? transcodingProtocol, [FromQuery] MediaStreamProtocol? transcodingProtocol,
[FromQuery] int? maxAudioSampleRate, [FromQuery] int? maxAudioSampleRate,
[FromQuery] int? maxAudioBitDepth, [FromQuery] int? maxAudioBitDepth,

View File

@@ -315,18 +315,18 @@ public class VideosController : BaseJellyfinApiController
[ProducesVideoFile] [ProducesVideoFile]
public async Task<ActionResult> GetVideoStream( public async Task<ActionResult> GetVideoStream(
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? container, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? container,
[FromQuery] bool? @static, [FromQuery] bool? @static,
[FromQuery] string? @params, [FromQuery] string? @params,
[FromQuery] string? tag, [FromQuery] string? tag,
[FromQuery, ParameterObsolete] string? deviceProfileId, [FromQuery, ParameterObsolete] string? deviceProfileId,
[FromQuery] string? playSessionId, [FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength, [FromQuery] int? segmentLength,
[FromQuery] int? minSegments, [FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId, [FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId, [FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy, [FromQuery] bool? allowAudioStreamCopy,
@@ -337,7 +337,7 @@ public class VideosController : BaseJellyfinApiController
[FromQuery] int? audioChannels, [FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels, [FromQuery] int? maxAudioChannels,
[FromQuery] string? profile, [FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate, [FromQuery] float? framerate,
[FromQuery] float? maxFramerate, [FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps, [FromQuery] bool? copyTimestamps,
@@ -358,8 +358,8 @@ public class VideosController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit, [FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId, [FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons, [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex, [FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex, [FromQuery] int? videoStreamIndex,
@@ -556,18 +556,18 @@ public class VideosController : BaseJellyfinApiController
[ProducesVideoFile] [ProducesVideoFile]
public Task<ActionResult> GetVideoStreamByContainer( public Task<ActionResult> GetVideoStreamByContainer(
[FromRoute, Required] Guid itemId, [FromRoute, Required] Guid itemId,
[FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string container, [FromRoute, Required] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string container,
[FromQuery] bool? @static, [FromQuery] bool? @static,
[FromQuery] string? @params, [FromQuery] string? @params,
[FromQuery] string? tag, [FromQuery] string? tag,
[FromQuery] string? deviceProfileId, [FromQuery] string? deviceProfileId,
[FromQuery] string? playSessionId, [FromQuery] string? playSessionId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? segmentContainer, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? segmentContainer,
[FromQuery] int? segmentLength, [FromQuery] int? segmentLength,
[FromQuery] int? minSegments, [FromQuery] int? minSegments,
[FromQuery] string? mediaSourceId, [FromQuery] string? mediaSourceId,
[FromQuery] string? deviceId, [FromQuery] string? deviceId,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? audioCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? audioCodec,
[FromQuery] bool? enableAutoStreamCopy, [FromQuery] bool? enableAutoStreamCopy,
[FromQuery] bool? allowVideoStreamCopy, [FromQuery] bool? allowVideoStreamCopy,
[FromQuery] bool? allowAudioStreamCopy, [FromQuery] bool? allowAudioStreamCopy,
@@ -578,7 +578,7 @@ public class VideosController : BaseJellyfinApiController
[FromQuery] int? audioChannels, [FromQuery] int? audioChannels,
[FromQuery] int? maxAudioChannels, [FromQuery] int? maxAudioChannels,
[FromQuery] string? profile, [FromQuery] string? profile,
[FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegex)] string? level, [FromQuery] [RegularExpression(EncodingHelper.LevelValidationRegexStr)] string? level,
[FromQuery] float? framerate, [FromQuery] float? framerate,
[FromQuery] float? maxFramerate, [FromQuery] float? maxFramerate,
[FromQuery] bool? copyTimestamps, [FromQuery] bool? copyTimestamps,
@@ -599,8 +599,8 @@ public class VideosController : BaseJellyfinApiController
[FromQuery] int? cpuCoreLimit, [FromQuery] int? cpuCoreLimit,
[FromQuery] string? liveStreamId, [FromQuery] string? liveStreamId,
[FromQuery] bool? enableMpegtsM2TsMode, [FromQuery] bool? enableMpegtsM2TsMode,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? videoCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? videoCodec,
[FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegex)] string? subtitleCodec, [FromQuery] [RegularExpression(EncodingHelper.ContainerValidationRegexStr)] string? subtitleCodec,
[FromQuery] string? transcodeReasons, [FromQuery] string? transcodeReasons,
[FromQuery] int? audioStreamIndex, [FromQuery] int? audioStreamIndex,
[FromQuery] int? videoStreamIndex, [FromQuery] int? videoStreamIndex,

View File

@@ -1,4 +1,8 @@
using System;
using System.Net.Mime; using System.Net.Mime;
using System.Text;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Formatters; using Microsoft.AspNetCore.Mvc.Formatters;
namespace Jellyfin.Api.Formatters; namespace Jellyfin.Api.Formatters;
@@ -6,7 +10,7 @@ namespace Jellyfin.Api.Formatters;
/// <summary> /// <summary>
/// Xml output formatter. /// Xml output formatter.
/// </summary> /// </summary>
public sealed class XmlOutputFormatter : StringOutputFormatter public sealed class XmlOutputFormatter : TextOutputFormatter
{ {
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class. /// Initializes a new instance of the <see cref="XmlOutputFormatter"/> class.
@@ -15,5 +19,24 @@ public sealed class XmlOutputFormatter : StringOutputFormatter
{ {
SupportedMediaTypes.Clear(); SupportedMediaTypes.Clear();
SupportedMediaTypes.Add(MediaTypeNames.Text.Xml); SupportedMediaTypes.Add(MediaTypeNames.Text.Xml);
SupportedEncodings.Add(Encoding.UTF8);
SupportedEncodings.Add(Encoding.Unicode);
}
/// <inheritdoc />
public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(selectedEncoding);
var valueAsString = context.Object?.ToString();
if (string.IsNullOrEmpty(valueAsString))
{
return;
}
var response = context.HttpContext.Response;
await response.WriteAsync(valueAsString, selectedEncoding).ConfigureAwait(false);
} }
} }

View File

@@ -154,7 +154,7 @@ public class DynamicHlsHelper
// from universal audio service, need to override the AudioCodec when the actual request differs from original query // from universal audio service, need to override the AudioCodec when the actual request differs from original query
if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase)) if (!string.Equals(state.OutputAudioCodec, _httpContextAccessor.HttpContext.Request.Query["AudioCodec"].ToString(), StringComparison.OrdinalIgnoreCase))
{ {
var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(_httpContextAccessor.HttpContext.Request.QueryString.ToString()); var newQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
newQuery["AudioCodec"] = state.OutputAudioCodec; newQuery["AudioCodec"] = state.OutputAudioCodec;
queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery); queryString = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(string.Empty, newQuery);
} }
@@ -173,10 +173,21 @@ public class DynamicHlsHelper
queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons; queryString += "&TranscodeReasons=" + state.Request.TranscodeReasons;
} }
// Main stream // Video rotation metadata is only supported in fMP4 remuxing
var playlistUrl = isLiveStream ? "live.m3u8" : "main.m3u8"; if (state.VideoStream is not null
&& state.VideoRequest is not null
&& (state.VideoStream?.Rotation ?? 0) != 0
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
{
queryString += "&AllowVideoStreamCopy=false";
}
playlistUrl += queryString; // Main stream
var baseUrl = isLiveStream ? "live.m3u8" : "main.m3u8";
var playlistUrl = baseUrl + queryString;
var playlistQuery = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
var subtitleStreams = state.MediaSource var subtitleStreams = state.MediaSource
.MediaStreams .MediaStreams
@@ -198,37 +209,36 @@ public class DynamicHlsHelper
AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User); AddSubtitles(state, subtitleStreams, builder, _httpContextAccessor.HttpContext.User);
} }
// Video rotation metadata is only supported in fMP4 remuxing
if (state.VideoStream is not null
&& state.VideoRequest is not null
&& (state.VideoStream?.Rotation ?? 0) != 0
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& !string.IsNullOrWhiteSpace(state.Request.SegmentContainer)
&& !string.Equals(state.Request.SegmentContainer, "mp4", StringComparison.OrdinalIgnoreCase))
{
playlistUrl += "&AllowVideoStreamCopy=false";
}
var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup); var basicPlaylist = AppendPlaylist(builder, state, playlistUrl, totalBitrate, subtitleGroup);
if (state.VideoStream is not null && state.VideoRequest is not null) if (state.VideoStream is not null && state.VideoRequest is not null)
{ {
var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); var encodingOptions = _serverConfigurationManager.GetEncodingOptions();
// Provide SDR HEVC entrance for backward compatibility. // Provide AV1 and HEVC SDR entrances for backward compatibility.
if (encodingOptions.AllowHevcEncoding foreach (var sdrVideoCodec in new[] { "av1", "hevc" })
&& !encodingOptions.AllowAv1Encoding {
var isAv1EncodingAllowed = encodingOptions.AllowAv1Encoding
&& string.Equals(sdrVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)
&& string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase);
var isHevcEncodingAllowed = encodingOptions.AllowHevcEncoding
&& string.Equals(sdrVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase)
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase);
var isEncodingAllowed = isAv1EncodingAllowed || isHevcEncodingAllowed;
if (isEncodingAllowed
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec) && EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.VideoRange == VideoRange.HDR && state.VideoStream.VideoRange == VideoRange.HDR)
&& string.Equals(state.ActualOutputVideoCodec, "hevc", StringComparison.OrdinalIgnoreCase))
{ {
var requestedVideoProfiles = state.GetRequestedProfiles("hevc"); // Force AV1 and HEVC Main Profile and disable video stream copy.
if (requestedVideoProfiles is not null && requestedVideoProfiles.Length > 0) state.OutputVideoCodec = sdrVideoCodec;
{
// Force HEVC Main Profile and disable video stream copy. var sdrPlaylistQuery = playlistQuery;
state.OutputVideoCodec = "hevc"; sdrPlaylistQuery["VideoCodec"] = sdrVideoCodec;
var sdrVideoUrl = ReplaceProfile(playlistUrl, "hevc", string.Join(',', requestedVideoProfiles), "main"); sdrPlaylistQuery[sdrVideoCodec + "-profile"] = "main";
sdrVideoUrl += "&AllowVideoStreamCopy=false"; sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range. // HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup); AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
@@ -238,12 +248,30 @@ public class DynamicHlsHelper
} }
} }
// Provide H.264 SDR entrance for backward compatibility.
if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.VideoRange == VideoRange.HDR)
{
// Force H.264 and disable video stream copy.
state.OutputVideoCodec = "h264";
var sdrPlaylistQuery = playlistQuery;
sdrPlaylistQuery["VideoCodec"] = "h264";
sdrPlaylistQuery["AllowVideoStreamCopy"] = "false";
var sdrVideoUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, sdrPlaylistQuery);
// HACK: Use the same bitrate so that the client can choose by other attributes, such as color range.
AppendPlaylist(builder, state, sdrVideoUrl, totalBitrate, subtitleGroup);
// Restore the video codec
state.OutputVideoCodec = "copy";
}
// Provide Level 5.0 entrance for backward compatibility. // Provide Level 5.0 entrance for backward compatibility.
// e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video, // e.g. Apple A10 chips refuse the master playlist containing SDR HEVC Main Level 5.1 video,
// but in fact it is capable of playing videos up to Level 6.1. // but in fact it is capable of playing videos up to Level 6.1.
if (encodingOptions.AllowHevcEncoding if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& !encodingOptions.AllowAv1Encoding
&& EncodingHelper.IsCopyCodec(state.OutputVideoCodec)
&& state.VideoStream.Level.HasValue && state.VideoStream.Level.HasValue
&& state.VideoStream.Level > 150 && state.VideoStream.Level > 150
&& state.VideoStream.VideoRange == VideoRange.SDR && state.VideoStream.VideoRange == VideoRange.SDR
@@ -273,12 +301,15 @@ public class DynamicHlsHelper
var variation = GetBitrateVariation(totalBitrate); var variation = GetBitrateVariation(totalBitrate);
var newBitrate = totalBitrate - variation; var newBitrate = totalBitrate - variation;
var variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); var variantQuery = playlistQuery;
variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
var variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
variation *= 2; variation *= 2;
newBitrate = totalBitrate - variation; newBitrate = totalBitrate - variation;
variantUrl = ReplaceVideoBitrate(playlistUrl, requestedVideoBitrate, requestedVideoBitrate - variation); variantQuery["VideoBitrate"] = (requestedVideoBitrate - variation).ToString(CultureInfo.InvariantCulture);
variantUrl = Microsoft.AspNetCore.WebUtilities.QueryHelpers.AddQueryString(baseUrl, variantQuery);
AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup); AppendPlaylist(builder, state, variantUrl, newBitrate, subtitleGroup);
} }
@@ -863,23 +894,6 @@ public class DynamicHlsHelper
return variation; return variation;
} }
private string ReplaceVideoBitrate(string url, int oldValue, int newValue)
{
return url.Replace(
"videobitrate=" + oldValue.ToString(CultureInfo.InvariantCulture),
"videobitrate=" + newValue.ToString(CultureInfo.InvariantCulture),
StringComparison.OrdinalIgnoreCase);
}
private string ReplaceProfile(string url, string codec, string oldValue, string newValue)
{
string profileStr = codec + "-profile=";
return url.Replace(
profileStr + oldValue,
profileStr + newValue,
StringComparison.OrdinalIgnoreCase);
}
private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue) private string ReplacePlaylistCodecsField(StringBuilder playlist, StringBuilder oldValue, StringBuilder newValue)
{ {
var oldPlaylist = playlist.ToString(); var oldPlaylist = playlist.ToString();

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Globalization; using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Api.Extensions; using Jellyfin.Api.Extensions;
@@ -17,9 +18,7 @@ using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Controller.Streaming; using MediaBrowser.Controller.Streaming;
using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Dlna;
using MediaBrowser.Model.Dto; using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Net.Http.Headers; using Microsoft.Net.Http.Headers;
namespace Jellyfin.Api.Helpers; namespace Jellyfin.Api.Helpers;
@@ -159,6 +158,13 @@ public static class StreamingHelpers
string? containerInternal = Path.GetExtension(state.RequestedUrl); string? containerInternal = Path.GetExtension(state.RequestedUrl);
if (string.IsNullOrEmpty(containerInternal)
&& (!string.IsNullOrWhiteSpace(streamingRequest.LiveStreamId)
|| (mediaSource != null && mediaSource.IsInfiniteStream)))
{
containerInternal = ".ts";
}
if (!string.IsNullOrEmpty(streamingRequest.Container)) if (!string.IsNullOrEmpty(streamingRequest.Container))
{ {
containerInternal = streamingRequest.Container; containerInternal = streamingRequest.Container;
@@ -415,14 +421,18 @@ public static class StreamingHelpers
request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); request.Static = string.Equals("true", val, StringComparison.OrdinalIgnoreCase);
break; break;
case 4: case 4:
if (videoRequest is not null) if (videoRequest is not null && IsValidCodecName(val))
{ {
videoRequest.VideoCodec = val; videoRequest.VideoCodec = val;
} }
break; break;
case 5: case 5:
if (IsValidCodecName(val))
{
request.AudioCodec = val; request.AudioCodec = val;
}
break; break;
case 6: case 6:
if (videoRequest is not null) if (videoRequest is not null)
@@ -476,7 +486,7 @@ public static class StreamingHelpers
request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture); request.StartTimeTicks = long.Parse(val, CultureInfo.InvariantCulture);
break; break;
case 15: case 15:
if (videoRequest is not null) if (videoRequest is not null && Regex.IsMatch(val, EncodingHelper.LevelValidationRegexStr))
{ {
videoRequest.Level = val; videoRequest.Level = val;
} }
@@ -497,7 +507,7 @@ public static class StreamingHelpers
break; break;
case 18: case 18:
if (videoRequest is not null) if (videoRequest is not null && IsValidCodecName(val))
{ {
videoRequest.Profile = val; videoRequest.Profile = val;
} }
@@ -556,7 +566,11 @@ public static class StreamingHelpers
break; break;
case 30: case 30:
if (IsValidCodecName(val))
{
request.SubtitleCodec = val; request.SubtitleCodec = val;
}
break; break;
case 31: case 31:
if (videoRequest is not null) if (videoRequest is not null)
@@ -579,6 +593,11 @@ public static class StreamingHelpers
} }
} }
private static bool IsValidCodecName(string val)
{
return EncodingHelper.ContainerValidationRegex().IsMatch(val);
}
/// <summary> /// <summary>
/// Parses the container into its file extension. /// Parses the container into its file extension.
/// </summary> /// </summary>

View File

@@ -1,3 +1,5 @@
using System.ComponentModel.DataAnnotations;
namespace Jellyfin.Api.Models.SyncPlayDtos; namespace Jellyfin.Api.Models.SyncPlayDtos;
/// <summary> /// <summary>
@@ -17,5 +19,6 @@ public class NewGroupRequestDto
/// Gets or sets the group name. /// Gets or sets the group name.
/// </summary> /// </summary>
/// <value>The name of the new group.</value> /// <value>The name of the new group.</value>
[StringLength(200, ErrorMessage = "Group name must not exceed 200 characters.")]
public string GroupName { get; set; } public string GroupName { get; set; }
} }

View File

@@ -7,6 +7,7 @@ using MediaBrowser.Controller.Authentication;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Net;
using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Session;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Session; using MediaBrowser.Model.Session;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -15,7 +16,7 @@ namespace Jellyfin.Api.WebSocketListeners;
/// <summary> /// <summary>
/// Class SessionInfoWebSocketListener. /// Class SessionInfoWebSocketListener.
/// </summary> /// </summary>
public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfo>, WebSocketListenerState> public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnumerable<SessionInfoDto>, WebSocketListenerState>
{ {
private readonly ISessionManager _sessionManager; private readonly ISessionManager _sessionManager;
private bool _disposed; private bool _disposed;
@@ -52,24 +53,26 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume
/// Gets the data to send. /// Gets the data to send.
/// </summary> /// </summary>
/// <returns>Task{SystemInfo}.</returns> /// <returns>Task{SystemInfo}.</returns>
protected override Task<IEnumerable<SessionInfo>> GetDataToSend() protected override Task<IEnumerable<SessionInfoDto>> GetDataToSend()
{ {
return Task.FromResult(_sessionManager.Sessions); return Task.FromResult(_sessionManager.Sessions.Select(_sessionManager.ToSessionInfoDto));
} }
/// <inheritdoc /> /// <inheritdoc />
protected override Task<IEnumerable<SessionInfo>> GetDataToSendForConnection(IWebSocketConnection connection) protected override Task<IEnumerable<SessionInfoDto>> GetDataToSendForConnection(IWebSocketConnection connection)
{ {
var sessions = _sessionManager.Sessions;
// For non-admin users, filter the sessions to only include their own sessions // For non-admin users, filter the sessions to only include their own sessions
if (connection.AuthorizationInfo?.User is not null && if (connection.AuthorizationInfo?.User is not null &&
!connection.AuthorizationInfo.IsApiKey && !connection.AuthorizationInfo.IsApiKey &&
!connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)) !connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator))
{ {
var userId = connection.AuthorizationInfo.User.Id; var userId = connection.AuthorizationInfo.User.Id;
return Task.FromResult(_sessionManager.Sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId))); sessions = sessions.Where(s => s.UserId.Equals(userId) || s.ContainsUser(userId));
} }
return Task.FromResult(_sessionManager.Sessions); return Task.FromResult(sessions.Select(_sessionManager.ToSessionInfoDto));
} }
/// <inheritdoc /> /// <inheritdoc />

View File

@@ -18,7 +18,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Data</PackageId> <PackageId>Jellyfin.Data</PackageId>
<VersionPrefix>10.11.0</VersionPrefix> <VersionPrefix>10.11.7</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>

View File

@@ -185,7 +185,7 @@ public static class UserEntityExtensions
entity.Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true)); entity.Permissions.Add(new Permission(PermissionKind.EnableSyncTranscoding, true));
entity.Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true)); entity.Permissions.Add(new Permission(PermissionKind.EnableAudioPlaybackTranscoding, true));
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true)); entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvAccess, true));
entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, true)); entity.Permissions.Add(new Permission(PermissionKind.EnableLiveTvManagement, false));
entity.Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true)); entity.Permissions.Add(new Permission(PermissionKind.EnableSharedDeviceControl, true));
entity.Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true)); entity.Permissions.Add(new Permission(PermissionKind.EnableVideoPlaybackTranscoding, true));
entity.Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false)); entity.Permissions.Add(new Permission(PermissionKind.ForceRemoteSourceTranscoding, false));

View File

@@ -118,17 +118,24 @@ public class BackupService : IBackupService
throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version."); throw new NotSupportedException($"The loaded archive '{archivePath}' is made for a newer version of Jellyfin ({manifest.ServerVersion}) and cannot be loaded in this version.");
} }
void CopyDirectory(string source, string target) void CopyDirectory(string source, string target, string[]? exclude = null)
{ {
var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar); var fullSourcePath = NormalizePathSeparator(Path.GetFullPath(source) + Path.DirectorySeparatorChar);
var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar; var fullTargetRoot = Path.GetFullPath(target) + Path.DirectorySeparatorChar;
var excludePaths = exclude?.Select(e => $"{source}/{e}/").ToArray();
foreach (var item in zipArchive.Entries) foreach (var item in zipArchive.Entries)
{ {
var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName)); var sourcePath = NormalizePathSeparator(Path.GetFullPath(item.FullName));
var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName))); var targetPath = Path.GetFullPath(Path.Combine(target, Path.GetRelativePath(source, item.FullName)));
if (excludePaths is not null && excludePaths.Any(e => item.FullName.StartsWith(e, StringComparison.Ordinal)))
{
continue;
}
if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal) if (!sourcePath.StartsWith(fullSourcePath, StringComparison.Ordinal)
|| !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)) || !targetPath.StartsWith(fullTargetRoot, StringComparison.Ordinal)
|| Path.EndsInDirectorySeparator(item.FullName))
{ {
continue; continue;
} }
@@ -141,8 +148,10 @@ public class BackupService : IBackupService
} }
CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath); CopyDirectory("Config", _applicationPaths.ConfigurationDirectoryPath);
CopyDirectory("Data", _applicationPaths.DataPath); CopyDirectory("Data", _applicationPaths.DataPath, exclude: ["metadata", "metadata-default"]);
CopyDirectory("Root", _applicationPaths.RootFolderPath); CopyDirectory("Root", _applicationPaths.RootFolderPath);
CopyDirectory("Data/metadata", _applicationPaths.InternalMetadataPath);
CopyDirectory("Data/metadata-default", _applicationPaths.DefaultInternalMetadataPath);
if (manifest.Options.Database) if (manifest.Options.Database)
{ {
@@ -199,7 +208,7 @@ public class BackupService : IBackupService
var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json"))); var zipEntry = zipArchive.GetEntry(NormalizePathSeparator(Path.Combine("Database", $"{entityType.Type.Name}.json")));
if (zipEntry is null) if (zipEntry is null)
{ {
_logger.LogInformation("No backup of expected table {Table} is present in backup. Continue anyway.", entityType.Type.Name); _logger.LogInformation("No backup of expected table {Table} is present in backup, continuing anyway", entityType.Type.Name);
continue; continue;
} }
@@ -223,7 +232,7 @@ public class BackupService : IBackupService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Could not store entity {Entity} continue anyway.", item); _logger.LogError(ex, "Could not store entity {Entity}, continuing anyway", item);
} }
} }
@@ -233,11 +242,11 @@ public class BackupService : IBackupService
_logger.LogInformation("Try restore Database"); _logger.LogInformation("Try restore Database");
await dbContext.SaveChangesAsync().ConfigureAwait(false); await dbContext.SaveChangesAsync().ConfigureAwait(false);
_logger.LogInformation("Restored database."); _logger.LogInformation("Restored database");
} }
} }
_logger.LogInformation("Restored Jellyfin system from {Date}.", manifest.DateCreated); _logger.LogInformation("Restored Jellyfin system from {Date}", manifest.DateCreated);
} }
} }
@@ -263,6 +272,8 @@ public class BackupService : IBackupService
Options = Map(backupOptions) Options = Map(backupOptions)
}; };
_logger.LogInformation("Running database optimization before backup");
await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false); await _jellyfinDatabaseProvider.RunScheduledOptimisation(CancellationToken.None).ConfigureAwait(false);
var backupFolder = Path.Combine(_applicationPaths.BackupPath); var backupFolder = Path.Combine(_applicationPaths.BackupPath);
@@ -281,16 +292,20 @@ public class BackupService : IBackupService
} }
var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip"); var backupPath = Path.Combine(backupFolder, $"jellyfin-backup-{manifest.DateCreated.ToLocalTime():yyyyMMddHHmmss}.zip");
_logger.LogInformation("Attempt to create a new backup at {BackupPath}", backupPath);
try
{
_logger.LogInformation("Attempting to create a new backup at {BackupPath}", backupPath);
var fileStream = File.OpenWrite(backupPath); var fileStream = File.OpenWrite(backupPath);
await using (fileStream.ConfigureAwait(false)) await using (fileStream.ConfigureAwait(false))
using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false)) using (var zipArchive = new ZipArchive(fileStream, ZipArchiveMode.Create, false))
{ {
_logger.LogInformation("Start backup process."); _logger.LogInformation("Starting backup process");
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false)) await using (dbContext.ConfigureAwait(false))
{ {
dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking; dbContext.ChangeTracker.QueryTrackingBehavior = QueryTrackingBehavior.NoTracking;
static IAsyncEnumerable<object> GetValues(IQueryable dbSet) static IAsyncEnumerable<object> GetValues(IQueryable dbSet)
{ {
var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!; var method = dbSet.GetType().GetMethod(nameof(DbSet<object>.AsAsyncEnumerable))!;
@@ -302,7 +317,8 @@ public class BackupService : IBackupService
var historyRepository = dbContext.GetService<IHistoryRepository>(); var historyRepository = dbContext.GetService<IHistoryRepository>();
var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false); var migrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes = [ ICollection<(Type Type, string SourceName, Func<IAsyncEnumerable<object>> ValueFactory)> entityTypes =
[
.. typeof(JellyfinDbContext) .. typeof(JellyfinDbContext)
.GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance) .GetProperties(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
.Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable))) .Where(e => e.PropertyType.IsAssignableTo(typeof(IQueryable)))
@@ -335,7 +351,8 @@ public class BackupService : IBackupService
entities++; entities++;
try try
{ {
JsonSerializer.SerializeToDocument(item, _serializerSettings).WriteTo(jsonSerializer); using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
document.WriteTo(jsonSerializer);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -348,7 +365,7 @@ public class BackupService : IBackupService
} }
} }
_logger.LogInformation("backup of entity {Table} with {Number} created", entityType.Type.Name, entities); _logger.LogInformation("Backup of entity {Table} with {Number} created", entityType.SourceName, entities);
} }
} }
} }
@@ -394,6 +411,15 @@ public class BackupService : IBackupService
if (backupOptions.Metadata) if (backupOptions.Metadata)
{ {
CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata")); CopyDirectory(Path.Combine(_applicationPaths.InternalMetadataPath), Path.Combine("Data", "metadata"));
// If a custom metadata path is configured, the default location may still contain data.
if (!string.Equals(
Path.GetFullPath(_applicationPaths.DefaultInternalMetadataPath),
Path.GetFullPath(_applicationPaths.InternalMetadataPath),
StringComparison.OrdinalIgnoreCase))
{
CopyDirectory(Path.Combine(_applicationPaths.DefaultInternalMetadataPath), Path.Combine("Data", "metadata-default"));
}
} }
var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open(); var manifestStream = zipArchive.CreateEntry(ManifestEntryName).Open();
@@ -406,6 +432,24 @@ public class BackupService : IBackupService
_logger.LogInformation("Backup created"); _logger.LogInformation("Backup created");
return Map(manifest, backupPath); return Map(manifest, backupPath);
} }
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create backup, removing {BackupPath}", backupPath);
try
{
if (File.Exists(backupPath))
{
File.Delete(backupPath);
}
}
catch (Exception innerEx)
{
_logger.LogWarning(innerEx, "Unable to remove failed backup");
}
throw;
}
}
/// <inheritdoc/> /// <inheritdoc/>
public async Task<BackupManifestDto?> GetBackupManifest(string archivePath) public async Task<BackupManifestDto?> GetBackupManifest(string archivePath)
@@ -422,7 +466,7 @@ public class BackupService : IBackupService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Tried to load archive from {Path} but failed.", archivePath); _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", archivePath);
return null; return null;
} }
@@ -459,7 +503,7 @@ public class BackupService : IBackupService
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Could not load {BackupArchive} path.", item); _logger.LogWarning(ex, "Tried to load manifest from archive {Path} but failed", item);
} }
} }

View File

@@ -275,6 +275,7 @@ public sealed class BaseItemRepository
} }
dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); result.Items = dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
result.StartIndex = filter.StartIndex ?? 0; result.StartIndex = filter.StartIndex ?? 0;
@@ -295,6 +296,26 @@ public sealed class BaseItemRepository
dbQuery = ApplyGroupingFilter(context, dbQuery, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter);
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
if (hasRandomSort)
{
var orderedIds = dbQuery.Select(e => e.Id).ToList();
if (orderedIds.Count == 0)
{
return Array.Empty<BaseItemDto>();
}
var itemsById = ApplyNavigations(context.BaseItems.Where(e => orderedIds.Contains(e.Id)), filter)
.AsEnumerable()
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
.Where(dto => dto is not null)
.ToDictionary(i => i!.Id);
return orderedIds.Where(itemsById.ContainsKey).Select(id => itemsById[id]).ToArray()!;
}
dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
} }
@@ -337,6 +358,8 @@ public sealed class BaseItemRepository
mainquery = ApplyGroupingFilter(context, mainquery, filter); mainquery = ApplyGroupingFilter(context, mainquery, filter);
mainquery = ApplyQueryPaging(mainquery, filter); mainquery = ApplyQueryPaging(mainquery, filter);
mainquery = ApplyNavigations(mainquery, filter);
return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray(); return mainquery.AsEnumerable().Where(e => e is not null).Select(w => DeserializeBaseItem(w, filter.SkipDeserialization)).ToArray();
} }
@@ -399,19 +422,32 @@ public sealed class BaseItemRepository
dbQuery = dbQuery.Distinct(); dbQuery = dbQuery.Distinct();
} }
dbQuery = ApplyOrder(dbQuery, filter); dbQuery = ApplyOrder(dbQuery, filter, context);
dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery; return dbQuery;
} }
private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter) private static IQueryable<BaseItemEntity> ApplyNavigations(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{ {
dbQuery = dbQuery.Include(e => e.TrailerTypes) if (filter.TrailerTypes.Length > 0 || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer))
.Include(e => e.Provider) {
.Include(e => e.LockedFields) dbQuery = dbQuery.Include(e => e.TrailerTypes);
.Include(e => e.UserData); }
if (filter.DtoOptions.ContainsField(ItemFields.ProviderIds))
{
dbQuery = dbQuery.Include(e => e.Provider);
}
if (filter.DtoOptions.ContainsField(ItemFields.Settings))
{
dbQuery = dbQuery.Include(e => e.LockedFields);
}
if (filter.DtoOptions.EnableUserData)
{
dbQuery = dbQuery.Include(e => e.UserData);
}
if (filter.DtoOptions.EnableImages) if (filter.DtoOptions.EnableImages)
{ {
@@ -446,6 +482,7 @@ public sealed class BaseItemRepository
dbQuery = TranslateQuery(dbQuery, context, filter); dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter); dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter); dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery; return dbQuery;
} }
@@ -599,7 +636,6 @@ public sealed class BaseItemRepository
var ids = tuples.Select(f => f.Item.Id).ToArray(); var ids = tuples.Select(f => f.Item.Id).ToArray();
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray(); var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
var newItems = tuples.Where(e => !existingItems.Contains(e.Item.Id)).ToArray();
foreach (var item in tuples) foreach (var item in tuples)
{ {
@@ -614,25 +650,25 @@ public sealed class BaseItemRepository
else else
{ {
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete(); context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
if (entity.Images is { Count: > 0 })
{
context.BaseItemImageInfos.AddRange(entity.Images);
}
if (entity.LockedFields is { Count: > 0 })
{
context.BaseItemMetadataFields.AddRange(entity.LockedFields);
}
context.BaseItems.Attach(entity).State = EntityState.Modified; context.BaseItems.Attach(entity).State = EntityState.Modified;
} }
} }
context.SaveChanges(); context.SaveChanges();
foreach (var item in newItems)
{
// reattach old userData entries
var userKeys = item.UserDataKey.ToArray();
var retentionDate = (DateTime?)null;
context.UserData
.Where(e => e.ItemId == PlaceholderId)
.Where(e => userKeys.Contains(e.CustomDataKey))
.ExecuteUpdate(e => e
.SetProperty(f => f.ItemId, item.Item.Id)
.SetProperty(f => f.RetentionDate, retentionDate));
}
var itemValueMaps = tuples var itemValueMaps = tuples
.Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags))) .Select(e => (e.Item, Values: GetItemValuesToSave(e.Item, e.InheritedTags)))
.ToArray(); .ToArray();
@@ -728,6 +764,43 @@ public sealed class BaseItemRepository
transaction.Commit(); transaction.Commit();
} }
/// <inheritdoc />
public async Task ReattachUserDataAsync(BaseItemDto item, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(item);
cancellationToken.ThrowIfCancellationRequested();
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken).ConfigureAwait(false);
await using (transaction.ConfigureAwait(false))
{
var userKeys = item.GetUserDataKeys().ToArray();
var retentionDate = (DateTime?)null;
await dbContext.UserData
.Where(e => e.ItemId == PlaceholderId)
.Where(e => userKeys.Contains(e.CustomDataKey))
.ExecuteUpdateAsync(
e => e
.SetProperty(f => f.ItemId, item.Id)
.SetProperty(f => f.RetentionDate, retentionDate),
cancellationToken).ConfigureAwait(false);
// Rehydrate the cached userdata
item.UserData = await dbContext.UserData
.AsNoTracking()
.Where(e => e.ItemId == item.Id)
.ToArrayAsync(cancellationToken)
.ConfigureAwait(false);
await transaction.CommitAsync(cancellationToken).ConfigureAwait(false);
}
}
}
/// <inheritdoc /> /// <inheritdoc />
public BaseItemDto? RetrieveItem(Guid id) public BaseItemDto? RetrieveItem(Guid id)
{ {
@@ -835,7 +908,7 @@ public sealed class BaseItemRepository
} }
dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray(); dto.ExtraIds = string.IsNullOrWhiteSpace(entity.ExtraIds) ? [] : entity.ExtraIds.Split('|').Select(e => Guid.Parse(e)).ToArray();
dto.ProductionLocations = entity.ProductionLocations?.Split('|') ?? []; dto.ProductionLocations = entity.ProductionLocations?.Split('|', StringSplitOptions.RemoveEmptyEntries) ?? [];
dto.Studios = entity.Studios?.Split('|') ?? []; dto.Studios = entity.Studios?.Split('|') ?? [];
dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|'); dto.Tags = string.IsNullOrWhiteSpace(entity.Tags) ? [] : entity.Tags.Split('|');
@@ -997,7 +1070,7 @@ public sealed class BaseItemRepository
} }
entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null; entity.ExtraIds = dto.ExtraIds is not null ? string.Join('|', dto.ExtraIds) : null;
entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations) : null; entity.ProductionLocations = dto.ProductionLocations is not null ? string.Join('|', dto.ProductionLocations.Where(p => !string.IsNullOrWhiteSpace(p))) : null;
entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null; entity.Studios = dto.Studios is not null ? string.Join('|', dto.Studios) : null;
entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null; entity.Tags = dto.Tags is not null ? string.Join('|', dto.Tags) : null;
entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields entity.LockedFields = dto.LockedFields is not null ? dto.LockedFields
@@ -1245,7 +1318,7 @@ public sealed class BaseItemRepository
.AsSingleQuery() .AsSingleQuery()
.Where(e => masterQuery.Contains(e.Id)); .Where(e => masterQuery.Contains(e.Id));
query = ApplyOrder(query, filter); query = ApplyOrder(query, filter, context);
var result = new QueryResult<(BaseItemDto, ItemCounts?)>(); var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
if (filter.EnableTotalRecordCount) if (filter.EnableTotalRecordCount)
@@ -1511,51 +1584,58 @@ public sealed class BaseItemRepository
|| query.IncludeItemTypes.Contains(BaseItemKind.Season); || query.IncludeItemTypes.Contains(BaseItemKind.Season);
} }
private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter) private IQueryable<BaseItemEntity> ApplyOrder(IQueryable<BaseItemEntity> query, InternalItemsQuery filter, JellyfinDbContext context)
{ {
var orderBy = filter.OrderBy; var orderBy = filter.OrderBy.Where(e => e.OrderBy != ItemSortBy.Default).ToArray();
var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm); var hasSearch = !string.IsNullOrEmpty(filter.SearchTerm);
if (hasSearch) if (hasSearch)
{ {
orderBy = filter.OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy]; orderBy = [(ItemSortBy.SortName, SortOrder.Ascending), .. orderBy];
} }
else if (orderBy.Count == 0) else if (orderBy.Length == 0)
{ {
return query.OrderBy(e => e.SortName); return query.OrderBy(e => e.SortName);
} }
IOrderedQueryable<BaseItemEntity>? orderedQuery = null; IOrderedQueryable<BaseItemEntity>? orderedQuery = null;
// When searching, prioritize by match quality: exact match > prefix match > contains
if (hasSearch)
{
orderedQuery = query.OrderBy(OrderMapper.MapSearchRelevanceOrder(filter.SearchTerm!));
}
var firstOrdering = orderBy.FirstOrDefault(); var firstOrdering = orderBy.FirstOrDefault();
if (firstOrdering != default) if (firstOrdering != default)
{ {
var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter); var expression = OrderMapper.MapOrderByField(firstOrdering.OrderBy, filter, context);
if (firstOrdering.SortOrder == SortOrder.Ascending) if (orderedQuery is null)
{ {
orderedQuery = query.OrderBy(expression); // No search relevance ordering, start fresh
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
? query.OrderBy(expression)
: query.OrderByDescending(expression);
} }
else else
{ {
orderedQuery = query.OrderByDescending(expression); // Search relevance ordering already applied, chain with ThenBy
orderedQuery = firstOrdering.SortOrder == SortOrder.Ascending
? orderedQuery.ThenBy(expression)
: orderedQuery.ThenByDescending(expression);
} }
if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName) if (firstOrdering.OrderBy is ItemSortBy.Default or ItemSortBy.SortName)
{ {
if (firstOrdering.SortOrder is SortOrder.Ascending) orderedQuery = firstOrdering.SortOrder is SortOrder.Ascending
{ ? orderedQuery.ThenBy(e => e.Name)
orderedQuery = orderedQuery.ThenBy(e => e.Name); : orderedQuery.ThenByDescending(e => e.Name);
}
else
{
orderedQuery = orderedQuery.ThenByDescending(e => e.Name);
}
} }
} }
foreach (var item in orderBy.Skip(1)) foreach (var item in orderBy.Skip(1))
{ {
var expression = OrderMapper.MapOrderByField(item.OrderBy, filter); var expression = OrderMapper.MapOrderByField(item.OrderBy, filter, context);
if (item.SortOrder == SortOrder.Ascending) if (item.SortOrder == SortOrder.Ascending)
{ {
orderedQuery = orderedQuery!.ThenBy(expression); orderedQuery = orderedQuery!.ThenBy(expression);
@@ -1637,19 +1717,18 @@ public sealed class BaseItemRepository
var tags = filter.Tags.ToList(); var tags = filter.Tags.ToList();
var excludeTags = filter.ExcludeTags.ToList(); var excludeTags = filter.ExcludeTags.ToList();
if (filter.IsMovie == true) if (filter.IsMovie.HasValue)
{ {
if (filter.IncludeItemTypes.Length == 0 var shouldIncludeAllMovieTypes = filter.IsMovie.Value
&& (filter.IncludeItemTypes.Length == 0
|| filter.IncludeItemTypes.Contains(BaseItemKind.Movie) || filter.IncludeItemTypes.Contains(BaseItemKind.Movie)
|| filter.IncludeItemTypes.Contains(BaseItemKind.Trailer)) || filter.IncludeItemTypes.Contains(BaseItemKind.Trailer));
if (!shouldIncludeAllMovieTypes)
{ {
baseQuery = baseQuery.Where(e => e.IsMovie); baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie.Value);
} }
} }
else if (filter.IsMovie.HasValue)
{
baseQuery = baseQuery.Where(e => e.IsMovie == filter.IsMovie);
}
if (filter.IsSeries.HasValue) if (filter.IsSeries.HasValue)
{ {
@@ -1694,15 +1773,16 @@ public sealed class BaseItemRepository
if (!string.IsNullOrEmpty(filter.SearchTerm)) if (!string.IsNullOrEmpty(filter.SearchTerm))
{ {
var searchTerm = filter.SearchTerm.ToLower(); var cleanedSearchTerm = GetCleanValue(filter.SearchTerm);
if (SearchWildcardTerms.Any(f => searchTerm.Contains(f))) var originalSearchTerm = filter.SearchTerm.ToLower();
if (SearchWildcardTerms.Any(f => cleanedSearchTerm.Contains(f)))
{ {
searchTerm = $"%{searchTerm.Trim('%')}%"; cleanedSearchTerm = $"%{cleanedSearchTerm.Trim('%')}%";
baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!.ToLower(), searchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), searchTerm))); baseQuery = baseQuery.Where(e => EF.Functions.Like(e.CleanName!, cleanedSearchTerm) || (e.OriginalTitle != null && EF.Functions.Like(e.OriginalTitle.ToLower(), originalSearchTerm)));
} }
else else
{ {
baseQuery = baseQuery.Where(e => e.CleanName!.ToLower().Contains(searchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(searchTerm))); baseQuery = baseQuery.Where(e => e.CleanName!.Contains(cleanedSearchTerm) || (e.OriginalTitle != null && e.OriginalTitle.ToLower().Contains(originalSearchTerm)));
} }
} }
@@ -1756,7 +1836,8 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.Path)) if (!string.IsNullOrWhiteSpace(filter.Path))
{ {
baseQuery = baseQuery.Where(e => e.Path == filter.Path); var pathToQuery = GetPathToSave(filter.Path);
baseQuery = baseQuery.Where(e => e.Path == pathToQuery);
} }
if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey)) if (!string.IsNullOrWhiteSpace(filter.PresentationUniqueKey))
@@ -1912,10 +1993,17 @@ public sealed class BaseItemRepository
} }
if (!string.IsNullOrWhiteSpace(filter.Name)) if (!string.IsNullOrWhiteSpace(filter.Name))
{
if (filter.UseRawName == true)
{
baseQuery = baseQuery.Where(e => e.Name == filter.Name);
}
else
{ {
var cleanName = GetCleanValue(filter.Name); var cleanName = GetCleanValue(filter.Name);
baseQuery = baseQuery.Where(e => e.CleanName == cleanName); baseQuery = baseQuery.Where(e => e.CleanName == cleanName);
} }
}
// These are the same, for now // These are the same, for now
var nameContains = filter.NameContains; var nameContains = filter.NameContains;
@@ -1936,19 +2024,20 @@ public sealed class BaseItemRepository
if (!string.IsNullOrWhiteSpace(filter.NameStartsWith)) if (!string.IsNullOrWhiteSpace(filter.NameStartsWith))
{ {
baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(filter.NameStartsWith)); var startsWithLower = filter.NameStartsWith.ToLowerInvariant();
baseQuery = baseQuery.Where(e => e.SortName!.StartsWith(startsWithLower));
} }
if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater)) if (!string.IsNullOrWhiteSpace(filter.NameStartsWithOrGreater))
{ {
// i hate this var startsOrGreaterLower = filter.NameStartsWithOrGreater.ToLowerInvariant();
baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() > filter.NameStartsWithOrGreater[0] || e.Name!.FirstOrDefault() > filter.NameStartsWithOrGreater[0]); baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(startsOrGreaterLower) >= 0);
} }
if (!string.IsNullOrWhiteSpace(filter.NameLessThan)) if (!string.IsNullOrWhiteSpace(filter.NameLessThan))
{ {
// i hate this var lessThanLower = filter.NameLessThan.ToLowerInvariant();
baseQuery = baseQuery.Where(e => e.SortName!.FirstOrDefault() < filter.NameLessThan[0] || e.Name!.FirstOrDefault() < filter.NameLessThan[0]); baseQuery = baseQuery.Where(e => e.SortName!.CompareTo(lessThanLower ) < 0);
} }
if (filter.ImageTypes.Length > 0) if (filter.ImageTypes.Length > 0)
@@ -2046,7 +2135,7 @@ public sealed class BaseItemRepository
if (filter.ExcludeArtistIds.Length > 0) if (filter.ExcludeArtistIds.Length > 0)
{ {
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Artist, filter.ExcludeArtistIds, true); baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
} }
if (filter.GenreIds.Count > 0) if (filter.GenreIds.Count > 0)
@@ -2353,17 +2442,23 @@ public sealed class BaseItemRepository
if (filter.HasImdbId.HasValue) if (filter.HasImdbId.HasValue)
{ {
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "imdb")); baseQuery = filter.HasImdbId.Value
? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Imdb.ToString().ToLower()))
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Imdb.ToString().ToLower()));
} }
if (filter.HasTmdbId.HasValue) if (filter.HasTmdbId.HasValue)
{ {
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tmdb")); baseQuery = filter.HasTmdbId.Value
? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tmdb.ToString().ToLower()))
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tmdb.ToString().ToLower()));
} }
if (filter.HasTvdbId.HasValue) if (filter.HasTvdbId.HasValue)
{ {
baseQuery = baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId == "tvdb")); baseQuery = filter.HasTvdbId.Value
? baseQuery.Where(e => e.Provider!.Any(f => f.ProviderId.ToLower() == MetadataProvider.Tvdb.ToString().ToLower()))
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != MetadataProvider.Tvdb.ToString().ToLower()));
} }
var queryTopParentIds = filter.TopParentIds; var queryTopParentIds = filter.TopParentIds;
@@ -2401,40 +2496,24 @@ public sealed class BaseItemRepository
if (filter.ExcludeInheritedTags.Length > 0) if (filter.ExcludeInheritedTags.Length > 0)
{ {
baseQuery = baseQuery var excludedTags = filter.ExcludeInheritedTags;
.Where(e => !e.ItemValues!.Where(w => w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags) baseQuery = baseQuery.Where(e =>
.Any(f => filter.ExcludeInheritedTags.Contains(f.ItemValue.CleanValue))); !e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))
&& (!e.SeriesId.HasValue || !context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && excludedTags.Contains(f.ItemValue.CleanValue))));
} }
if (filter.IncludeInheritedTags.Length > 0) if (filter.IncludeInheritedTags.Length > 0)
{ {
// Episodes do not store inherit tags from their parents in the database, and the tag may be still required by the client. var includeTags = filter.IncludeInheritedTags;
// In addition to the tags for the episodes themselves, we need to manually query its parent (the season)'s tags as well. var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Episode) baseQuery = baseQuery.Where(e =>
{ e.ItemValues!.Any(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
baseQuery = baseQuery
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
||
(e.ParentId.HasValue && context.ItemValuesMap.Where(w => w.ItemId == e.ParentId.Value && (w.ItemValue.Type == ItemValueType.InheritedTags || w.ItemValue.Type == ItemValueType.Tags))
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))));
}
// A playlist should be accessible to its owner regardless of allowed tags. // For seasons and episodes, we also need to check the parent series' tags.
else if (includeTypes.Length == 1 && includeTypes.FirstOrDefault() is BaseItemKind.Playlist) || (e.SeriesId.HasValue && context.ItemValuesMap.Any(f => f.ItemId == e.SeriesId.Value && f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue)))
{
baseQuery = baseQuery // A playlist should be accessible to its owner regardless of allowed tags
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags) || (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue))
|| e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\""));
// d ^^ this is stupid it hate this.
}
else
{
baseQuery = baseQuery
.Where(e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.InheritedTags || f.ItemValue.Type == ItemValueType.Tags)
.Any(f => filter.IncludeInheritedTags.Contains(f.ItemValue.CleanValue)));
}
} }
if (filter.SeriesStatuses.Length > 0) if (filter.SeriesStatuses.Length > 0)
@@ -2588,6 +2667,21 @@ public sealed class BaseItemRepository
.Where(e => artistNames.Contains(e.Name)) .Where(e => artistNames.Contains(e.Name))
.ToArray(); .ToArray();
return artists.GroupBy(e => e.Name).ToDictionary(e => e.Key!, e => e.Select(f => DeserializeBaseItem(f)).Cast<MusicArtist>().ToArray()); var lookup = artists
.GroupBy(e => e.Name!)
.ToDictionary(
g => g.Key,
g => g.Select(f => DeserializeBaseItem(f)).Where(dto => dto is not null).Cast<MusicArtist>().ToArray());
var result = new Dictionary<string, MusicArtist[]>(artistNames.Count);
foreach (var name in artistNames)
{
if (lookup.TryGetValue(name, out var artistArray))
{
result[name] = artistArray;
}
}
return result;
} }
} }

View File

@@ -1,8 +1,12 @@
#pragma warning disable RS0030 // Do not use banned APIs
using System; using System;
using System.Linq; using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities; using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -18,40 +22,77 @@ public static class OrderMapper
/// </summary> /// </summary>
/// <param name="sortBy">Item property to sort by.</param> /// <param name="sortBy">Item property to sort by.</param>
/// <param name="query">Context Query.</param> /// <param name="query">Context Query.</param>
/// <param name="jellyfinDbContext">Context.</param>
/// <returns>Func to be executed later for sorting query.</returns> /// <returns>Func to be executed later for sorting query.</returns>
public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query) public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext)
{ {
return sortBy switch return (sortBy, query.User) switch
{ {
ItemSortBy.AirTime => e => e.SortName, // TODO (ItemSortBy.AirTime, _) => e => e.SortName, // TODO
ItemSortBy.Runtime => e => e.RunTimeTicks, (ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
ItemSortBy.Random => e => EF.Functions.Random(), (ItemSortBy.Random, _) => e => EF.Functions.Random(),
ItemSortBy.DatePlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate, (ItemSortBy.DatePlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.LastPlayedDate,
ItemSortBy.PlayCount => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount, (ItemSortBy.PlayCount, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.PlayCount,
ItemSortBy.IsFavoriteOrLiked => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite, (ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.IsFavorite,
ItemSortBy.IsFolder => e => e.IsFolder, (ItemSortBy.IsFolder, _) => e => e.IsFolder,
ItemSortBy.IsPlayed => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played, (ItemSortBy.IsPlayed, _) => e => e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
ItemSortBy.IsUnplayed => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played, (ItemSortBy.IsUnplayed, _) => e => !e.UserData!.FirstOrDefault(f => f.UserId.Equals(query.User!.Id))!.Played,
ItemSortBy.DateLastContentAdded => e => e.DateLastMediaAdded, (ItemSortBy.DateLastContentAdded, _) => e => e.DateLastMediaAdded,
ItemSortBy.Artist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), (ItemSortBy.Artist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Artist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
ItemSortBy.AlbumArtist => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), (ItemSortBy.AlbumArtist, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.AlbumArtist).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
ItemSortBy.Studio => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(), (ItemSortBy.Studio, _) => e => e.ItemValues!.Where(f => f.ItemValue.Type == ItemValueType.Studios).Select(f => f.ItemValue.CleanValue).FirstOrDefault(),
ItemSortBy.OfficialRating => e => e.InheritedParentalRatingValue, (ItemSortBy.OfficialRating, _) => e => e.InheritedParentalRatingValue,
// ItemSortBy.SeriesDatePlayed => "(Select MAX(LastPlayedDate) from TypedBaseItems B" + GetJoinUserDataText(query) + " where Played=1 and B.SeriesPresentationUniqueKey=A.PresentationUniqueKey)", (ItemSortBy.SeriesSortName, _) => e => e.SeriesName,
ItemSortBy.SeriesSortName => e => e.SeriesName, (ItemSortBy.Album, _) => e => e.Album,
(ItemSortBy.DateCreated, _) => e => e.DateCreated,
(ItemSortBy.PremiereDate, _) => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
(ItemSortBy.StartDate, _) => e => e.StartDate,
(ItemSortBy.Name, _) => e => e.CleanName,
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
(ItemSortBy.CriticRating, _) => e => e.CriticRating,
(ItemSortBy.VideoBitRate, _) => e => e.TotalBitrate,
(ItemSortBy.ParentIndexNumber, _) => e => e.ParentIndexNumber,
(ItemSortBy.IndexNumber, _) => e => e.IndexNumber,
(ItemSortBy.SeriesDatePlayed, not null) => e =>
jellyfinDbContext.BaseItems
.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Join(jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id && w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
.Max(f => f),
(ItemSortBy.SeriesDatePlayed, null) => e => jellyfinDbContext.BaseItems.Where(w => w.SeriesPresentationUniqueKey == e.PresentationUniqueKey)
.Join(jellyfinDbContext.UserData.Where(w => w.Played), f => f.Id, f => f.ItemId, (item, userData) => userData.LastPlayedDate)
.Max(f => f),
// ItemSortBy.SeriesDatePlayed => e => jellyfinDbContext.UserData
// .Where(u => u.Item!.SeriesPresentationUniqueKey == e.PresentationUniqueKey && u.Played)
// .Max(f => f.LastPlayedDate),
// ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder", // ItemSortBy.AiredEpisodeOrder => "AiredEpisodeOrder",
ItemSortBy.Album => e => e.Album,
ItemSortBy.DateCreated => e => e.DateCreated,
ItemSortBy.PremiereDate => e => (e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null)),
ItemSortBy.StartDate => e => e.StartDate,
ItemSortBy.Name => e => e.CleanName,
ItemSortBy.CommunityRating => e => e.CommunityRating,
ItemSortBy.ProductionYear => e => e.ProductionYear,
ItemSortBy.CriticRating => e => e.CriticRating,
ItemSortBy.VideoBitRate => e => e.TotalBitrate,
ItemSortBy.ParentIndexNumber => e => e.ParentIndexNumber,
ItemSortBy.IndexNumber => e => e.IndexNumber,
_ => e => e.SortName _ => e => e.SortName
}; };
} }
/// <summary>
/// Creates an expression to order search results by match quality.
/// Prioritizes: exact match (0) > prefix match with word boundary (1) > prefix match (2) > contains (3).
/// </summary>
/// <param name="searchTerm">The search term to match against.</param>
/// <returns>An expression that returns an integer representing match quality (lower is better).</returns>
public static Expression<Func<BaseItemEntity, int>> MapSearchRelevanceOrder(string searchTerm)
{
var cleanSearchTerm = GetCleanValue(searchTerm);
var searchPrefix = cleanSearchTerm + " ";
return e =>
e.CleanName == cleanSearchTerm ? 0 :
e.CleanName!.StartsWith(searchPrefix) ? 1 :
e.CleanName!.StartsWith(cleanSearchTerm) ? 2 : 3;
}
private static string GetCleanValue(string value)
{
if (string.IsNullOrWhiteSpace(value))
{
return value;
}
return value.RemoveDiacritics().ToLowerInvariant();
}
} }

View File

@@ -13,8 +13,7 @@ namespace Jellyfin.Server.Implementations.StorageHelpers;
public static class StorageHelper public static class StorageHelper
{ {
private const long TwoGigabyte = 2_147_483_647L; private const long TwoGigabyte = 2_147_483_647L;
private const long FiveHundredAndTwelveMegaByte = 536_870_911L; private static readonly string[] _byteHumanizedSuffixes = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"];
private static readonly string[] _byteHumanizedSuffixes = ["B", "KB", "MB", "GB", "TB", "PB", "EB"];
/// <summary> /// <summary>
/// Tests the available storage capacity on the jellyfin paths with estimated minimum values. /// Tests the available storage capacity on the jellyfin paths with estimated minimum values.
@@ -24,10 +23,8 @@ public static class StorageHelper
public static void TestCommonPathsForStorageCapacity(IApplicationPaths applicationPaths, ILogger logger) public static void TestCommonPathsForStorageCapacity(IApplicationPaths applicationPaths, ILogger logger)
{ {
TestDataDirectorySize(applicationPaths.DataPath, logger, TwoGigabyte); TestDataDirectorySize(applicationPaths.DataPath, logger, TwoGigabyte);
TestDataDirectorySize(applicationPaths.LogDirectoryPath, logger, FiveHundredAndTwelveMegaByte);
TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte); TestDataDirectorySize(applicationPaths.CachePath, logger, TwoGigabyte);
TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte); TestDataDirectorySize(applicationPaths.ProgramDataPath, logger, TwoGigabyte);
TestDataDirectorySize(applicationPaths.TempDirectory, logger, TwoGigabyte);
} }
/// <summary> /// <summary>
@@ -77,7 +74,7 @@ public static class StorageHelper
var drive = new DriveInfo(path); var drive = new DriveInfo(path);
if (threshold != -1 && drive.AvailableFreeSpace < threshold) if (threshold != -1 && drive.AvailableFreeSpace < threshold)
{ {
throw new InvalidOperationException($"The path `{path}` has insufficient free space. Required: at least {HumanizeStorageSize(threshold)}."); throw new InvalidOperationException($"The path `{path}` has insufficient free space. Available: {HumanizeStorageSize(drive.AvailableFreeSpace)}, Required: {HumanizeStorageSize(threshold)}.");
} }
logger.LogInformation( logger.LogInformation(

View File

@@ -254,10 +254,10 @@ public class TrickplayManager : ITrickplayManager
} }
// We support video backdrops, but we should not generate trickplay images for them // We support video backdrops, but we should not generate trickplay images for them
var parentDirectory = Directory.GetParent(mediaPath); var parentDirectory = Directory.GetParent(video.Path);
if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.OrdinalIgnoreCase)) if (parentDirectory is not null && string.Equals(parentDirectory.Name, "backdrops", StringComparison.OrdinalIgnoreCase))
{ {
_logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", mediaPath, video.Id); _logger.LogDebug("Ignoring backdrop media found at {Path} for item {ItemID}", video.Path, video.Id);
return; return;
} }

View File

@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.IO; using System.IO;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
@@ -92,33 +93,38 @@ namespace Jellyfin.Server.Implementations.Users
} }
/// <inheritdoc /> /// <inheritdoc />
public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork) public async Task<ForgotPasswordResult> StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork)
{
DateTime expireTime = DateTime.UtcNow.AddMinutes(30);
var usernameHash = enteredUsername.ToUpperInvariant().GetMD5().ToString("N", CultureInfo.InvariantCulture);
var pinFile = _passwordResetFileBase + usernameHash + ".json";
if (user is not null && isInNetwork)
{ {
byte[] bytes = new byte[4]; byte[] bytes = new byte[4];
RandomNumberGenerator.Fill(bytes); RandomNumberGenerator.Fill(bytes);
string pin = BitConverter.ToString(bytes); string pin = BitConverter.ToString(bytes);
DateTime expireTime = DateTime.UtcNow.AddMinutes(30);
string filePath = _passwordResetFileBase + user.Id + ".json";
SerializablePasswordReset spr = new SerializablePasswordReset SerializablePasswordReset spr = new SerializablePasswordReset
{ {
ExpirationDate = expireTime, ExpirationDate = expireTime,
Pin = pin, Pin = pin,
PinFile = filePath, PinFile = pinFile,
UserName = user.Username UserName = user.Username
}; };
FileStream fileStream = AsyncFile.Create(filePath); FileStream fileStream = AsyncFile.Create(pinFile);
await using (fileStream.ConfigureAwait(false)) await using (fileStream.ConfigureAwait(false))
{ {
await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false); await JsonSerializer.SerializeAsync(fileStream, spr).ConfigureAwait(false);
} }
}
return new ForgotPasswordResult return new ForgotPasswordResult
{ {
Action = ForgotPasswordAction.PinCode, Action = ForgotPasswordAction.PinCode,
PinExpirationDate = expireTime, PinExpirationDate = expireTime,
PinFile = filePath PinFile = pinFile
}; };
} }

View File

@@ -149,7 +149,7 @@ namespace Jellyfin.Server.Implementations.Users
ThrowIfInvalidUsername(newName); ThrowIfInvalidUsername(newName);
if (user.Username.Equals(newName, StringComparison.OrdinalIgnoreCase)) if (user.Username.Equals(newName, StringComparison.Ordinal))
{ {
throw new ArgumentException("The new and old names must be different."); throw new ArgumentException("The new and old names must be different.");
} }
@@ -508,23 +508,18 @@ namespace Jellyfin.Server.Implementations.Users
public async Task<ForgotPasswordResult> StartForgotPasswordProcess(string enteredUsername, bool isInNetwork) public async Task<ForgotPasswordResult> StartForgotPasswordProcess(string enteredUsername, bool isInNetwork)
{ {
var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername); var user = string.IsNullOrWhiteSpace(enteredUsername) ? null : GetUserByName(enteredUsername);
var passwordResetProvider = GetPasswordResetProvider(user);
var result = await passwordResetProvider
.StartForgotPasswordProcess(user, enteredUsername, isInNetwork)
.ConfigureAwait(false);
if (user is not null && isInNetwork) if (user is not null && isInNetwork)
{ {
var passwordResetProvider = GetPasswordResetProvider(user);
var result = await passwordResetProvider
.StartForgotPasswordProcess(user, isInNetwork)
.ConfigureAwait(false);
await UpdateUserAsync(user).ConfigureAwait(false); await UpdateUserAsync(user).ConfigureAwait(false);
return result;
} }
return new ForgotPasswordResult return result;
{
Action = ForgotPasswordAction.InNetworkRequired,
PinFile = string.Empty
};
} }
/// <inheritdoc/> /// <inheritdoc/>
@@ -760,8 +755,13 @@ namespace Jellyfin.Server.Implementations.Users
return GetAuthenticationProviders(user)[0]; return GetAuthenticationProviders(user)[0];
} }
private IPasswordResetProvider GetPasswordResetProvider(User user) private IPasswordResetProvider GetPasswordResetProvider(User? user)
{ {
if (user is null)
{
return _defaultPasswordResetProvider;
}
return GetPasswordResetProviders(user)[0]; return GetPasswordResetProviders(user)[0];
} }

View File

@@ -33,9 +33,11 @@ using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Cors.Infrastructure; using Microsoft.AspNetCore.Cors.Infrastructure;
using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Any;
using Microsoft.OpenApi.Interfaces; using Microsoft.OpenApi.Interfaces;
using Microsoft.OpenApi.Models; using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.Swagger;
using Swashbuckle.AspNetCore.SwaggerGen; using Swashbuckle.AspNetCore.SwaggerGen;
using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes; using AuthenticationSchemes = Jellyfin.Api.Constants.AuthenticationSchemes;
@@ -259,7 +261,8 @@ namespace Jellyfin.Server.Extensions
c.OperationFilter<FileRequestFilter>(); c.OperationFilter<FileRequestFilter>();
c.OperationFilter<ParameterObsoleteFilter>(); c.OperationFilter<ParameterObsoleteFilter>();
c.DocumentFilter<AdditionalModelFilter>(); c.DocumentFilter<AdditionalModelFilter>();
}); })
.Replace(ServiceDescriptor.Transient<ISwaggerProvider, CachingOpenApiProvider>());
} }
private static void AddPolicy(this AuthorizationOptions authorizationOptions, string policyName, IAuthorizationRequirement authorizationRequirement) private static void AddPolicy(this AuthorizationOptions authorizationOptions, string policyName, IAuthorizationRequirement authorizationRequirement)

View File

@@ -0,0 +1,89 @@
using System;
using System.Threading;
using Microsoft.AspNetCore.Mvc.ApiExplorer;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Options;
using Microsoft.OpenApi.Models;
using Swashbuckle.AspNetCore.Swagger;
using Swashbuckle.AspNetCore.SwaggerGen;
namespace Jellyfin.Server.Filters;
/// <summary>
/// OpenApi provider with caching.
/// </summary>
internal sealed class CachingOpenApiProvider : ISwaggerProvider
{
private const string CacheKey = "openapi.json";
private static readonly MemoryCacheEntryOptions _cacheOptions = new() { SlidingExpiration = TimeSpan.FromMinutes(5) };
private static readonly SemaphoreSlim _lock = new(1, 1);
private static readonly TimeSpan _lockTimeout = TimeSpan.FromSeconds(1);
private readonly IMemoryCache _memoryCache;
private readonly SwaggerGenerator _swaggerGenerator;
private readonly SwaggerGeneratorOptions _swaggerGeneratorOptions;
/// <summary>
/// Initializes a new instance of the <see cref="CachingOpenApiProvider"/> class.
/// </summary>
/// <param name="optionsAccessor">The options accessor.</param>
/// <param name="apiDescriptionsProvider">The api descriptions provider.</param>
/// <param name="schemaGenerator">The schema generator.</param>
/// <param name="memoryCache">The memory cache.</param>
public CachingOpenApiProvider(
IOptions<SwaggerGeneratorOptions> optionsAccessor,
IApiDescriptionGroupCollectionProvider apiDescriptionsProvider,
ISchemaGenerator schemaGenerator,
IMemoryCache memoryCache)
{
_swaggerGeneratorOptions = optionsAccessor.Value;
_swaggerGenerator = new SwaggerGenerator(_swaggerGeneratorOptions, apiDescriptionsProvider, schemaGenerator);
_memoryCache = memoryCache;
}
/// <inheritdoc />
public OpenApiDocument GetSwagger(string documentName, string? host = null, string? basePath = null)
{
if (_memoryCache.TryGetValue(CacheKey, out OpenApiDocument? openApiDocument) && openApiDocument is not null)
{
return AdjustDocument(openApiDocument, host, basePath);
}
var acquired = _lock.Wait(_lockTimeout);
try
{
if (_memoryCache.TryGetValue(CacheKey, out openApiDocument) && openApiDocument is not null)
{
return AdjustDocument(openApiDocument, host, basePath);
}
if (!acquired)
{
throw new InvalidOperationException("OpenApi document is generating");
}
openApiDocument = _swaggerGenerator.GetSwagger(documentName);
_memoryCache.Set(CacheKey, openApiDocument, _cacheOptions);
return AdjustDocument(openApiDocument, host, basePath);
}
finally
{
if (acquired)
{
_lock.Release();
}
}
}
private OpenApiDocument AdjustDocument(OpenApiDocument document, string? host, string? basePath)
{
document.Servers = _swaggerGeneratorOptions.Servers.Count != 0
? _swaggerGeneratorOptions.Servers
: string.IsNullOrEmpty(host) && string.IsNullOrEmpty(basePath)
? []
: [new OpenApiServer { Url = $"{host}{basePath}" }];
return document;
}
}

View File

@@ -1,151 +0,0 @@
// The MIT License (MIT)
//
// Copyright (c) .NET Foundation and Contributors
//
// All rights reserved.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Extensions;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.Extensions.Logging;
using Microsoft.Net.Http.Headers;
namespace Jellyfin.Server.Infrastructure
{
/// <inheritdoc />
public class SymlinkFollowingPhysicalFileResultExecutor : PhysicalFileResultExecutor
{
/// <summary>
/// Initializes a new instance of the <see cref="SymlinkFollowingPhysicalFileResultExecutor"/> class.
/// </summary>
/// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param>
public SymlinkFollowingPhysicalFileResultExecutor(ILoggerFactory loggerFactory) : base(loggerFactory)
{
}
/// <inheritdoc />
protected override FileMetadata GetFileInfo(string path)
{
var fileInfo = new FileInfo(path);
var length = fileInfo.Length;
// This may or may not be fixed in .NET 6, but looks like it will not https://github.com/dotnet/aspnetcore/issues/34371
if ((fileInfo.Attributes & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint)
{
using var fileHandle = File.OpenHandle(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
length = RandomAccess.GetLength(fileHandle);
}
return new FileMetadata
{
Exists = fileInfo.Exists,
Length = length,
LastModified = fileInfo.LastWriteTimeUtc
};
}
/// <inheritdoc />
protected override async Task WriteFileAsync(ActionContext context, PhysicalFileResult result, RangeItemHeaderValue? range, long rangeLength)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(result);
if (range is not null && rangeLength == 0)
{
return;
}
// It's a bit of wasted IO to perform this check again, but non-symlinks shouldn't use this code
if (!IsSymLink(result.FileName))
{
await base.WriteFileAsync(context, result, range, rangeLength).ConfigureAwait(false);
return;
}
var response = context.HttpContext.Response;
if (range is not null)
{
await SendFileAsync(
result.FileName,
response,
offset: range.From ?? 0L,
count: rangeLength).ConfigureAwait(false);
return;
}
await SendFileAsync(
result.FileName,
response,
offset: 0,
count: null).ConfigureAwait(false);
}
private async Task SendFileAsync(string filePath, HttpResponse response, long offset, long? count, CancellationToken cancellationToken = default)
{
var fileInfo = GetFileInfo(filePath);
if (offset < 0 || offset > fileInfo.Length)
{
throw new ArgumentOutOfRangeException(nameof(offset), offset, string.Empty);
}
if (count.HasValue
&& (count.Value < 0 || count.Value > fileInfo.Length - offset))
{
throw new ArgumentOutOfRangeException(nameof(count), count, string.Empty);
}
// Copied from SendFileFallback.SendFileAsync
const int BufferSize = 1024 * 16;
var useRequestAborted = !cancellationToken.CanBeCanceled;
var localCancel = useRequestAborted ? response.HttpContext.RequestAborted : cancellationToken;
var fileStream = new FileStream(
filePath,
FileMode.Open,
FileAccess.Read,
FileShare.ReadWrite,
bufferSize: BufferSize,
options: FileOptions.Asynchronous | FileOptions.SequentialScan);
await using (fileStream.ConfigureAwait(false))
{
try
{
localCancel.ThrowIfCancellationRequested();
fileStream.Seek(offset, SeekOrigin.Begin);
await StreamCopyOperation
.CopyToAsync(fileStream, response.Body, count, BufferSize, localCancel)
.ConfigureAwait(true);
}
catch (OperationCanceledException) when (useRequestAborted)
{
}
}
}
private static bool IsSymLink(string path) => (File.GetAttributes(path) & FileAttributes.ReparsePoint) == FileAttributes.ReparsePoint;
}
}

View File

@@ -78,7 +78,7 @@
<None Update="wwwroot\api-docs\swagger\custom.css"> <None Update="wwwroot\api-docs\swagger\custom.css">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<None Update="wwwroot\api-docs\banner-dark.svg"> <None Update="wwwroot\api-docs\jellyfin.svg">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory> <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None> </None>
<None Update="ServerSetupApp/index.mstemplate.html"> <None Update="ServerSetupApp/index.mstemplate.html">

View File

@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Globalization;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to fix broken library subtitle download languages.
/// </summary>
[JellyfinMigration("2026-02-06T20:00:00", nameof(FixLibrarySubtitleDownloadLanguages))]
internal class FixLibrarySubtitleDownloadLanguages : IAsyncMigrationRoutine
{
private readonly ILocalizationManager _localizationManager;
private readonly ILibraryManager _libraryManager;
private readonly ILogger _logger;
/// <summary>
/// Initializes a new instance of the <see cref="FixLibrarySubtitleDownloadLanguages"/> class.
/// </summary>
/// <param name="localizationManager">The Localization manager.</param>
/// <param name="startupLogger">The startup logger for Startup UI integration.</param>
/// <param name="libraryManager">The Library manager.</param>
/// <param name="logger">The logger.</param>
public FixLibrarySubtitleDownloadLanguages(
ILocalizationManager localizationManager,
IStartupLogger<FixLibrarySubtitleDownloadLanguages> startupLogger,
ILibraryManager libraryManager,
ILogger<FixLibrarySubtitleDownloadLanguages> logger)
{
_localizationManager = localizationManager;
_libraryManager = libraryManager;
_logger = startupLogger.With(logger);
}
/// <inheritdoc />
public Task PerformAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting to fix library subtitle download languages.");
var virtualFolders = _libraryManager.GetVirtualFolders(false);
foreach (var virtualFolder in virtualFolders)
{
var options = virtualFolder.LibraryOptions;
if (options.SubtitleDownloadLanguages is null || options.SubtitleDownloadLanguages.Length == 0)
{
continue;
}
// Some virtual folders don't have a proper item id.
if (!Guid.TryParse(virtualFolder.ItemId, out var folderId))
{
continue;
}
var collectionFolder = _libraryManager.GetItemById<CollectionFolder>(folderId);
if (collectionFolder is null)
{
_logger.LogWarning("Could not find collection folder for virtual folder '{LibraryName}' with id '{FolderId}'. Skipping.", virtualFolder.Name, folderId);
continue;
}
var fixedLanguages = new List<string>();
foreach (var language in options.SubtitleDownloadLanguages)
{
var foundLanguage = _localizationManager.FindLanguageInfo(language)?.ThreeLetterISOLanguageName;
if (foundLanguage is not null)
{
// Converted ISO 639-2/B to T (ger to deu)
if (!string.Equals(foundLanguage, language, StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Converted '{Language}' to '{ResolvedLanguage}' in library '{LibraryName}'.", language, foundLanguage, virtualFolder.Name);
}
if (fixedLanguages.Contains(foundLanguage, StringComparer.OrdinalIgnoreCase))
{
_logger.LogInformation("Language '{Language}' already exists for library '{LibraryName}'. Skipping duplicate.", foundLanguage, virtualFolder.Name);
continue;
}
fixedLanguages.Add(foundLanguage);
}
else
{
_logger.LogInformation("Could not resolve language '{Language}' in library '{LibraryName}'. Skipping.", language, virtualFolder.Name);
}
}
options.SubtitleDownloadLanguages = [.. fixedLanguages];
collectionFolder.UpdateLibraryOptions(options);
}
_logger.LogInformation("Library subtitle download languages fixed.");
return Task.CompletedTask;
}
}

View File

@@ -55,9 +55,25 @@ namespace Jellyfin.Server.Migrations.Routines
}; };
var dataPath = _paths.DataPath; var dataPath = _paths.DataPath;
using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}")) var activityLogPath = Path.Combine(dataPath, DbFilename);
if (!File.Exists(activityLogPath))
{
_logger.LogWarning("{ActivityLogDb} doesn't exist, nothing to migrate", activityLogPath);
return;
}
using (var connection = new SqliteConnection($"Filename={activityLogPath}"))
{ {
connection.Open(); connection.Open();
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='ActivityLog';");
foreach (var row in tableQuery)
{
if (row.GetInt32(0) == 0)
{
_logger.LogWarning("Table 'ActivityLog' doesn't exist in {ActivityLogPath}, nothing to migrate", activityLogPath);
return;
}
}
using var userDbConnection = new SqliteConnection($"Filename={Path.Combine(dataPath, "users.db")}"); using var userDbConnection = new SqliteConnection($"Filename={Path.Combine(dataPath, "users.db")}");
userDbConnection.Open(); userDbConnection.Open();

View File

@@ -50,9 +50,28 @@ namespace Jellyfin.Server.Migrations.Routines
public void Perform() public void Perform()
{ {
var dataPath = _appPaths.DataPath; var dataPath = _appPaths.DataPath;
using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}")) var dbFilePath = Path.Combine(dataPath, DbFilename);
if (!File.Exists(dbFilePath))
{
_logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath);
return;
}
using (var connection = new SqliteConnection($"Filename={dbFilePath}"))
{ {
connection.Open(); connection.Open();
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='Tokens';");
foreach (var row in tableQuery)
{
if (row.GetInt32(0) == 0)
{
_logger.LogWarning("Table 'Tokens' doesn't exist in {Path}, nothing to migrate", dbFilePath);
return;
}
}
using var dbContext = _dbProvider.CreateDbContext(); using var dbContext = _dbProvider.CreateDbContext();
var authenticatedDevices = connection.Query("SELECT * FROM Tokens"); var authenticatedDevices = connection.Query("SELECT * FROM Tokens");

View File

@@ -78,9 +78,27 @@ namespace Jellyfin.Server.Migrations.Routines
var displayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var displayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var customDisplayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var customDisplayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var dbFilePath = Path.Combine(_paths.DataPath, DbFilename); var dbFilePath = Path.Combine(_paths.DataPath, DbFilename);
if (!File.Exists(dbFilePath))
{
_logger.LogWarning("{Path} doesn't exist, nothing to migrate", dbFilePath);
return;
}
using (var connection = new SqliteConnection($"Filename={dbFilePath}")) using (var connection = new SqliteConnection($"Filename={dbFilePath}"))
{ {
connection.Open(); connection.Open();
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='userdisplaypreferences';");
foreach (var row in tableQuery)
{
if (row.GetInt32(0) == 0)
{
_logger.LogWarning("Table 'userdisplaypreferences' doesn't exist in {Path}, nothing to migrate", dbFilePath);
return;
}
}
using var dbContext = _provider.CreateDbContext(); using var dbContext = _provider.CreateDbContext();
var results = connection.Query("SELECT * FROM userdisplaypreferences"); var results = connection.Query("SELECT * FROM userdisplaypreferences");

View File

@@ -122,6 +122,16 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
{ {
lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath); lastWriteTimeUtc = File.GetLastWriteTimeUtc(filePath);
} }
catch (ArgumentOutOfRangeException e)
{
_logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
return null;
}
catch (UnauthorizedAccessException e)
{
_logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
return null;
}
catch (IOException e) catch (IOException e)
{ {
_logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message); _logger.LogDebug("Skipping {Path}: {Exception}", filePath, e.Message);
@@ -135,15 +145,22 @@ public class MigrateKeyframeData : IDatabaseMigrationRoutine
return Path.Join(keyframeCachePath, prefix, filename); return Path.Join(keyframeCachePath, prefix, filename);
} }
private static bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult) private bool TryReadFromCache(string? cachePath, [NotNullWhen(true)] out MediaEncoding.Keyframes.KeyframeData? cachedResult)
{ {
if (File.Exists(cachePath)) if (File.Exists(cachePath))
{
try
{ {
var bytes = File.ReadAllBytes(cachePath); var bytes = File.ReadAllBytes(cachePath);
cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions); cachedResult = JsonSerializer.Deserialize<MediaEncoding.Keyframes.KeyframeData>(bytes, _jsonOptions);
return cachedResult is not null; return cachedResult is not null;
} }
catch (JsonException jsonException)
{
_logger.LogWarning(jsonException, "Failed to read {Path}", cachePath);
}
}
cachedResult = null; cachedResult = null;

View File

@@ -383,8 +383,6 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
}); });
} }
baseItemIds.Clear();
foreach (var item in peopleCache) foreach (var item in peopleCache)
{ {
operation.JellyfinDbContext.Peoples.Add(item.Value.Person); operation.JellyfinDbContext.Peoples.Add(item.Value.Person);
@@ -466,6 +464,16 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
SqliteConnection.ClearAllPools(); SqliteConnection.ClearAllPools();
using (var checkpointConnection = new SqliteConnection($"Filename={libraryDbPath}"))
{
checkpointConnection.Open();
using var cmd = checkpointConnection.CreateCommand();
cmd.CommandText = "PRAGMA wal_checkpoint(TRUNCATE);";
cmd.ExecuteNonQuery();
}
SqliteConnection.ClearAllPools();
_logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old"); _logger.LogInformation("Move {0} to {1}.", libraryDbPath, libraryDbPath + ".old");
File.Move(libraryDbPath, libraryDbPath + ".old", true); File.Move(libraryDbPath, libraryDbPath + ".old", true);
} }
@@ -1165,7 +1173,9 @@ internal class MigrateLibraryDb : IDatabaseMigrationRoutine
Item = null!, Item = null!,
ProviderId = e[0], ProviderId = e[0],
ProviderValue = string.Join('|', e.Skip(1)) ProviderValue = string.Join('|', e.Skip(1))
}).ToArray(); })
.DistinctBy(e => e.ProviderId)
.ToArray();
} }
if (reader.TryGetString(index++, out var imageInfos)) if (reader.TryGetString(index++, out var imageInfos))

View File

@@ -57,11 +57,28 @@ public class MigrateUserDb : IMigrationRoutine
public void Perform() public void Perform()
{ {
var dataPath = _paths.DataPath; var dataPath = _paths.DataPath;
var userDbPath = Path.Combine(dataPath, DbFilename);
if (!File.Exists(userDbPath))
{
_logger.LogWarning("{UserDbPath} doesn't exist, nothing to migrate", userDbPath);
return;
}
_logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin."); _logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin.");
using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}")) using (var connection = new SqliteConnection($"Filename={userDbPath}"))
{ {
connection.Open(); connection.Open();
var tableQuery = connection.Query("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='LocalUsersv2';");
foreach (var row in tableQuery)
{
if (row.GetInt32(0) == 0)
{
_logger.LogWarning("Table 'LocalUsersv2' doesn't exist in {UserDbPath}, nothing to migrate", userDbPath);
return;
}
}
using var dbContext = _provider.CreateDbContext(); using var dbContext = _provider.CreateDbContext();
var queryResult = connection.Query("SELECT * FROM LocalUsersv2"); var queryResult = connection.Query("SELECT * FROM LocalUsersv2");

View File

@@ -224,6 +224,18 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
return null; return null;
} }
catch (UnauthorizedAccessException e)
{
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
return null;
}
catch (ArgumentOutOfRangeException e)
{
_logger.LogDebug("Skipping attachment at index {Index} for {Path}: {Exception}", attachmentStreamIndex, mediaPath, e.Message);
return null;
}
filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture); filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture) + "_" + date.Value.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture);
} }
@@ -263,6 +275,18 @@ public class MoveExtractedFiles : IAsyncMigrationRoutine
{ {
date = File.GetLastWriteTimeUtc(path); date = File.GetLastWriteTimeUtc(path);
} }
catch (ArgumentOutOfRangeException e)
{
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
return null;
}
catch (UnauthorizedAccessException e)
{
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);
return null;
}
catch (IOException e) catch (IOException e)
{ {
_logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message); _logger.LogDebug("Skipping subtitle at index {Index} for {Path}: {Exception}", streamIndex, path, e.Message);

View File

@@ -184,6 +184,12 @@ namespace Jellyfin.Server
.AddSingleton<IServiceCollection>(e)) .AddSingleton<IServiceCollection>(e))
.Build(); .Build();
/*
* Initialize the transcode path marker so we avoid starting Jellyfin in a broken state.
* This should really be a part of IApplicationPaths but this path is configured differently.
*/
_ = appHost.ConfigurationManager.GetTranscodePath();
// Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection. // Re-use the host service provider in the app host since ASP.NET doesn't allow a custom service collection.
appHost.ServiceProvider = _jellyfinHost.Services; appHost.ServiceProvider = _jellyfinHost.Services;
PrepareDatabaseProvider(appHost.ServiceProvider); PrepareDatabaseProvider(appHost.ServiceProvider);

View File

@@ -250,6 +250,7 @@ public sealed class SetupServer : IDisposable
{ "isInReportingMode", _isUnhealthy }, { "isInReportingMode", _isUnhealthy },
{ "retryValue", retryAfterValue }, { "retryValue", retryAfterValue },
{ "logs", startupLogEntries }, { "logs", startupLogEntries },
{ "networkManagerReady", networkManager is not null },
{ "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) } { "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
}, },
new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions)) new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))

View File

@@ -213,7 +213,12 @@
</ol> </ol>
</div> </div>
{{#ELSE}} {{#ELSE}}
{{#IF networkManagerReady}}
<p>Please visit this page from your local network to view detailed startup logs.</p> <p>Please visit this page from your local network to view detailed startup logs.</p>
{{#ELSE}}
<p>Initializing network settings. Please wait.</p>
{{/ELSE}}
{{/IF}}
{{/ELSE}} {{/ELSE}}
{{/IF}} {{/IF}}
</div> </div>

View File

@@ -16,15 +16,12 @@ using Jellyfin.Networking.HappyEyeballs;
using Jellyfin.Server.Extensions; using Jellyfin.Server.Extensions;
using Jellyfin.Server.HealthChecks; using Jellyfin.Server.HealthChecks;
using Jellyfin.Server.Implementations.Extensions; using Jellyfin.Server.Implementations.Extensions;
using Jellyfin.Server.Infrastructure;
using MediaBrowser.Common.Net; using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Extensions;
using MediaBrowser.XbmcMetadata; using MediaBrowser.XbmcMetadata;
using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Infrastructure;
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
@@ -69,8 +66,6 @@ namespace Jellyfin.Server
options.HttpsPort = _serverApplicationHost.HttpsPort; options.HttpsPort = _serverApplicationHost.HttpsPort;
}); });
// TODO remove once this is fixed upstream https://github.com/dotnet/aspnetcore/issues/34371
services.AddSingleton<IActionResultExecutor<PhysicalFileResult>, SymlinkFollowingPhysicalFileResultExecutor>();
services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration()); services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration());
services.AddJellyfinDbContext(_serverApplicationHost.ConfigurationManager, _configuration); services.AddJellyfinDbContext(_serverApplicationHost.ConfigurationManager, _configuration);
services.AddJellyfinApiSwagger(); services.AddJellyfinApiSwagger();

View File

@@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- ***** BEGIN LICENSE BLOCK *****
- Part of the Jellyfin project (https://jellyfin.media)
-
- All copyright belongs to the Jellyfin contributors; a full list can
- be found in the file CONTRIBUTORS.md
-
- This work is licensed under the Creative Commons Attribution-ShareAlike 4.0 International License.
- To view a copy of this license, visit http://creativecommons.org/licenses/by-sa/4.0/.
- ***** END LICENSE BLOCK ***** -->
<svg id="banner-dark" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1536 512">
<defs>
<linearGradient id="linear-gradient" x1="110.25" y1="213.3" x2="496.14" y2="436.09" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#aa5cc3"/>
<stop offset="1" stop-color="#00a4dc"/>
</linearGradient>
</defs>
<title>banner-dark</title>
<g id="banner-dark">
<g id="banner-dark-icon">
<path id="inner-shape" d="M261.42,201.62c-20.44,0-86.24,119.29-76.2,139.43s142.48,19.92,152.4,0S281.86,201.63,261.42,201.62Z" fill="url(#linear-gradient)"/>
<path id="outer-shape" d="M261.42,23.3C199.83,23.3,1.57,382.73,31.8,443.43s429.34,60,459.24,0S323,23.3,261.42,23.3ZM411.9,390.76c-19.59,39.33-281.08,39.77-300.9,0S221.1,115.48,261.45,115.48,431.49,351.42,411.9,390.76Z" fill="url(#linear-gradient)"/>
</g>
<g id="jellyfin-light-outlines" style="isolation:isolate" transform="translate(43.8)">
<path d="M556.64,350.75a67,67,0,0,1-22.87-27.47,8.91,8.91,0,0,1-1.49-4.75,7.42,7.42,0,0,1,2.83-5.94,9.25,9.25,0,0,1,6.09-2.38c3.16,0,5.94,1.69,8.31,5.05a48.09,48.09,0,0,0,16.34,20.34,40.59,40.59,0,0,0,24,7.58q20.51,0,33.27-12.62t12.77-33.12V159a8.44,8.44,0,0,1,2.67-6.39,9.56,9.56,0,0,1,6.83-2.52,9,9,0,0,1,6.68,2.52,8.7,8.7,0,0,1,2.53,6.39v138.4a64.7,64.7,0,0,1-8.32,32.67,59,59,0,0,1-23,22.72Q608.62,361,589.9,361A57.21,57.21,0,0,1,556.64,350.75Z" fill="#fff"/>
<path d="M831.66,279.47a8.77,8.77,0,0,1-6.24,2.53H713.16q0,17.82,7.27,31.92a54.91,54.91,0,0,0,20.79,22.28q13.51,8.18,31.93,8.17a54,54,0,0,0,25.54-5.94,52.7,52.7,0,0,0,18.12-15.15,10,10,0,0,1,6.24-2.67,8.14,8.14,0,0,1,7.72,7.72,8.81,8.81,0,0,1-3,6.24,74.7,74.7,0,0,1-23.91,19A65.56,65.56,0,0,1,773.45,361q-22.87,0-40.4-9.8a69.51,69.51,0,0,1-27.32-27.48q-9.79-17.66-9.8-40.83,0-24.36,9.65-42.62t25.69-27.92a65.2,65.2,0,0,1,34.16-9.65A70,70,0,0,1,798.84,211a65.78,65.78,0,0,1,25.39,24.36q9.81,16,10.1,38A8.07,8.07,0,0,1,831.66,279.47ZM733.5,231.8Q718.8,243.68,714.64,266H815.92v-2.38A46.91,46.91,0,0,0,807,240.27a48.47,48.47,0,0,0-18.56-15.15,54,54,0,0,0-23-5.2Q748.2,219.92,733.5,231.8Z" fill="#fff"/>
<path d="M888.24,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,888.24,355.5Z" fill="#fff"/>
<path d="M956.55,355.5a8.92,8.92,0,0,1-15.3-6.38v-202a8.91,8.91,0,1,1,17.82,0v202A8.65,8.65,0,0,1,956.55,355.5Z" fill="#fff"/>
<path d="M1122.86,206.11a8.7,8.7,0,0,1,2.53,6.39v131q0,23.44-9.21,40.09a61.58,61.58,0,0,1-25.54,25.25q-16.34,8.61-36.83,8.61a96.73,96.73,0,0,1-23.31-2.68,61.72,61.72,0,0,1-18-7.12q-6.24-3.87-6.24-8.62a17.94,17.94,0,0,1,.6-3,8.06,8.06,0,0,1,3-4.45,7.49,7.49,0,0,1,4.45-1.49,7.91,7.91,0,0,1,3.56.89q19,10.39,36.24,10.4,24.65,0,39.06-15.44t14.4-42.18V333.38a54.37,54.37,0,0,1-21.38,20,62.55,62.55,0,0,1-30.3,7.58q-25.83,0-39.2-15.45t-13.37-41.87V212.5a8.91,8.91,0,1,1,17.82,0V301q0,21.39,9.36,32.38t29.25,11a48,48,0,0,0,23.32-6.09,49.88,49.88,0,0,0,17.82-16,37.44,37.44,0,0,0,6.68-21.24V212.5a9,9,0,0,1,15.29-6.39Z" fill="#fff"/>
<path d="M1210.18,161.41q-5.21,6.24-5.2,17.23v30.59h33.27a8.19,8.19,0,0,1,5.79,2.38,8.26,8.26,0,0,1,0,11.88,8.22,8.22,0,0,1-5.79,2.37H1205V349.12a8.91,8.91,0,1,1-17.82,0V225.86h-21.68a7.83,7.83,0,0,1-5.94-2.52,8.21,8.21,0,0,1-2.37-5.79,8,8,0,0,1,2.37-6.09,8.33,8.33,0,0,1,5.94-2.23h21.68V178.64q0-18.7,10.84-29t29-10.24a46.1,46.1,0,0,1,15.45,2.52q7.13,2.53,7.12,8.17a8.07,8.07,0,0,1-2.37,5.94,7.37,7.37,0,0,1-5.35,2.37,18.81,18.81,0,0,1-6.53-1.48,42,42,0,0,0-10.4-1.78Q1215.37,155.18,1210.18,161.41ZM1276,180.87c-2.19-1.88-3.27-4.61-3.27-8.17v-3q0-5.34,3.41-8.17t9.36-2.82q11.88,0,11.88,11v3c0,3.56-1,6.29-3.12,8.17s-5.1,2.82-9.06,2.82S1278.14,182.75,1276,180.87Zm15.59,174.63a8.92,8.92,0,0,1-15.3-6.38V212.5a8.91,8.91,0,1,1,17.82,0V349.12A8.65,8.65,0,0,1,1291.56,355.5Z" fill="#fff"/>
<path d="M1452.53,218.88q12.92,16.2,12.92,42.92v87.32a8.4,8.4,0,0,1-2.67,6.38,8.8,8.8,0,0,1-6.24,2.53,8.64,8.64,0,0,1-8.91-8.91V262.69q0-19.31-9.65-31.33t-29.85-12a53.28,53.28,0,0,0-42.77,21.83,36.24,36.24,0,0,0-7.13,21.53v86.43a8.91,8.91,0,1,1-17.82,0V216.06a8.91,8.91,0,1,1,17.82,0V232.4q8-12.77,23-21.24A61.84,61.84,0,0,1,1412,202.7Q1439.61,202.7,1452.53,218.88Z" fill="#fff"/>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,26 @@
<svg xmlns="http://www.w3.org/2000/svg" width="251" height="72" fill="none" viewBox="0 0 251 72">
<g clip-path="url(#a)">
<path fill="url(#b)"
d="M24.212 49.158C22.66 46.042 32.838 27.588 36 27.588c3.167.002 13.323 18.488 11.788 21.57-1.534 3.082-22.025 3.116-23.576 0" />
<path fill="url(#c)" fill-rule="evenodd"
d="M.482 64.995C-4.195 55.605 26.477 0 36 0c9.533 0 40.153 55.713 35.527 64.995s-66.368 9.39-71.045 0m12.254-8.148c3.064 6.152 43.518 6.084 46.548 0 3.03-6.086-17.032-42.586-23.275-42.586S9.671 50.694 12.736 56.847"
clip-rule="evenodd" />
<path fill="#fff"
d="M225.22 56c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.219-.218c-.054-.107-.054-.247-.054-.527V26.8c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.183c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v2.895a7.9 7.9 0 0 1 3.419-3.254q2.261-1.103 5.074-1.103 3.308 0 5.845 1.434a10.1 10.1 0 0 1 4.026 4.026q1.434 2.536 1.434 5.9V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.218c-.107.055-.247.055-.527.055h-5.625c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527V38.408q0-2.978-1.709-4.688-1.654-1.764-4.357-1.764-2.702 0-4.412 1.764-1.654 1.766-1.654 4.688V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm-11.54-33.363c-.28 0-.42 0-.527-.055a.5.5 0 0 1-.218-.218c-.055-.107-.055-.247-.055-.527v-6.121c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527v6.12c0 .28 0 .42-.054.528a.5.5 0 0 1-.219.218c-.107.055-.247.055-.527.055zm0 33.363c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.219c-.055-.107-.055-.247-.055-.527V26.8c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h5.624c.28 0 .42 0 .527.055a.5.5 0 0 1 .219.218c.054.107.054.247.054.527v28.4c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-16.712-.054c.107.054.247.054.527.054h5.625c.28 0 .42 0 .526-.054a.5.5 0 0 0 .219-.219c.055-.107.055-.247.055-.527V32.452h5.872c.28 0 .42 0 .527-.054a.5.5 0 0 0 .219-.219c.054-.107.054-.247.054-.527V26.8c0-.28 0-.42-.054-.527a.5.5 0 0 0-.219-.218c-.107-.055-.247-.055-.527-.055h-5.872v-.992q0-2.261 1.323-3.31 1.379-1.102 3.75-1.102.454 0 .939.044c.345.031.518.047.634-.004a.48.48 0 0 0 .241-.22c.061-.111.061-.274.061-.6V15.39c0-.304 0-.457-.061-.589a.7.7 0 0 0-.248-.284c-.122-.078-.261-.097-.537-.136a14.5 14.5 0 0 0-1.966-.126q-5.184 0-8.273 2.812t-3.088 7.942V26H186.53c-.3 0-.451 0-.58.05a.75.75 0 0 0-.296.205c-.091.104-.143.244-.248.526l-7.43 19.9-7.483-19.903c-.105-.28-.158-.42-.249-.524a.75.75 0 0 0-.296-.205c-.129-.049-.279-.049-.578-.049h-5.769c-.394 0-.591 0-.717.083a.5.5 0 0 0-.213.314c-.031.147.041.33.186.697L174.281 56l-.661 1.6q-.883 1.874-2.041 3.033-1.103 1.158-3.584 1.158-.883 0-1.875-.166a13 13 0 0 1-.73-.1c-.389-.066-.584-.099-.709-.053a.47.47 0 0 0-.26.22c-.066.116-.066.298-.066.663v4.329c0 .243 0 .365.045.481a.7.7 0 0 0 .189.266c.095.081.194.116.392.185q.684.24 1.47.351 1.158.22 2.371.22 4.246 0 7.059-2.426 2.867-2.37 4.577-6.728l10.517-26.58h5.72V55.2c0 .28 0 .42.055.527a.5.5 0 0 0 .218.219M154.363 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527V55.2c0 .28 0 .42-.055.527a.5.5 0 0 1-.218.219c-.107.054-.247.054-.527.054zm-11.621 0c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.219-.219c-.054-.107-.054-.247-.054-.527V15.054c0-.28 0-.42.054-.527a.5.5 0 0 1 .219-.219c.107-.054.247-.054.527-.054h5.624c.28 0 .42 0 .527.054a.5.5 0 0 1 .219.219c.054.107.054.247.054.527V55.2c0 .28 0 .42-.054.527a.5.5 0 0 1-.219.219c-.107.054-.247.054-.527.054zm-18.132.662q-4.632-.001-8.107-2.096a14.6 14.6 0 0 1-5.404-5.68q-1.93-3.585-1.93-7.942 0-4.522 1.93-7.996 1.985-3.53 5.349-5.57 3.42-2.04 7.61-2.04 4.688 0 7.942 2.04 3.253 1.986 4.963 5.294 1.71 3.309 1.709 7.335 0 .828-.11 1.654-.031.45-.12.841c-.037.165-.055.247-.115.33a.55.55 0 0 1-.208.168c-.095.04-.194.04-.393.04h-21.057q.33 3.309 2.537 5.294 2.205 1.986 5.459 1.985 2.482 0 4.191-1.047a8.2 8.2 0 0 0 2.206-1.986c.241-.316.362-.474.484-.542a.6.6 0 0 1 .352-.083c.139.006.296.083.608.236l4.269 2.094c.239.118.359.176.431.275a.52.52 0 0 1 .098.298c0 .122-.058.231-.172.45q-1.432 2.742-4.526 4.607-3.419 2.04-7.996 2.04m-.552-25.368q-2.702 0-4.687 1.654-1.93 1.6-2.537 4.577h14.118q-.22-2.757-2.151-4.466-1.875-1.765-4.743-1.765M90.801 56c-.28 0-.42 0-.527-.054a.5.5 0 0 1-.218-.218C90 55.62 90 55.48 90 55.2v-5.294c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.218c.107-.055.247-.055.527-.055h1.572q2.646 0 4.19-1.489 1.6-1.545 1.6-4.08V15.715c0-.28 0-.42.055-.527a.5.5 0 0 1 .218-.219c.107-.054.247-.054.527-.054h5.956c.28 0 .42 0 .527.054a.5.5 0 0 1 .218.219c.055.107.055.247.055.527v27.546q0 3.804-1.655 6.672-1.599 2.868-4.632 4.467-2.979 1.6-7.06 1.6z" />
</g>
<defs>
<linearGradient id="b" x1="12" x2="71.999" y1="30.001" y2="63.002"
gradientUnits="userSpaceOnUse">
<stop stop-color="#aa5cc3" />
<stop offset="1" stop-color="#00a4dc" />
</linearGradient>
<linearGradient id="c" x1="12" x2="71.999" y1="29.999" y2="63.001"
gradientUnits="userSpaceOnUse">
<stop stop-color="#aa5cc3" />
<stop offset="1" stop-color="#00a4dc" />
</linearGradient>
<clipPath id="a">
<path fill="#fff" d="M0 0h251v72H0z" />
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.3 KiB

View File

@@ -4,12 +4,14 @@
} }
.topbar-wrapper .link:after { .topbar-wrapper .link:after {
content: url(../banner-dark.svg); content: '';
display: block; display: block;
-moz-box-sizing: border-box; background-image: url(../jellyfin.svg);
background-position: center;
background-repeat: no-repeat;
background-size: contain;
box-sizing: border-box; box-sizing: border-box;
max-width: 100%; width: 220px;
max-height: 100%; height: 40px;
width: 150px;
} }
/* end logo */ /* end logo */

View File

@@ -103,11 +103,11 @@ namespace MediaBrowser.Common.Configuration
void MakeSanityCheckOrThrow(); void MakeSanityCheckOrThrow();
/// <summary> /// <summary>
/// Checks and creates the given path and adds it with a marker file if non existant. /// Checks and creates the given path and adds it with a marker file if non existent.
/// </summary> /// </summary>
/// <param name="path">The path to check.</param> /// <param name="path">The path to check.</param>
/// <param name="markerName">The common marker file name.</param> /// <param name="markerName">The common marker file name.</param>
/// <param name="recursive">Check for other settings paths recursivly.</param> /// <param name="recursive">Check for other settings paths recursively.</param>
void CreateAndCheckMarker(string path, string markerName, bool recursive = false); void CreateAndCheckMarker(string path, string markerName, bool recursive = false);
} }
} }

View File

@@ -8,7 +8,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Common</PackageId> <PackageId>Jellyfin.Common</PackageId>
<VersionPrefix>10.11.0</VersionPrefix> <VersionPrefix>10.11.7</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>

View File

@@ -1,5 +1,3 @@
#nullable disable
#pragma warning disable CS1591 #pragma warning disable CS1591
using System; using System;
@@ -15,11 +13,12 @@ namespace MediaBrowser.Controller.Authentication
bool IsEnabled { get; } bool IsEnabled { get; }
Task<ForgotPasswordResult> StartForgotPasswordProcess(User user, bool isInNetwork); Task<ForgotPasswordResult> StartForgotPasswordProcess(User? user, string enteredUsername, bool isInNetwork);
Task<PinRedeemResult> RedeemPasswordResetPin(string pin); Task<PinRedeemResult> RedeemPasswordResetPin(string pin);
} }
#nullable disable
public class PasswordPinCreationResult public class PasswordPinCreationResult
{ {
public string PinFile { get; set; } public string PinFile { get; set; }

View File

@@ -24,6 +24,7 @@ using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Dto;
using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.IO;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaSegments; using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence; using MediaBrowser.Controller.Persistence;
@@ -1127,6 +1128,15 @@ namespace MediaBrowser.Controller.Entities
var protocol = item.PathProtocol; var protocol = item.PathProtocol;
// Resolve the item path so everywhere we use the media source it will always point to
// the correct path even if symlinks are in use. Calling ResolveLinkTarget on a non-link
// path will return null, so it's safe to check for all paths.
var itemPath = item.Path;
if (protocol is MediaProtocol.File && FileSystemHelper.ResolveLinkTarget(itemPath, returnFinalTarget: true) is { Exists: true } linkInfo)
{
itemPath = linkInfo.FullName;
}
var info = new MediaSourceInfo var info = new MediaSourceInfo
{ {
Id = item.Id.ToString("N", CultureInfo.InvariantCulture), Id = item.Id.ToString("N", CultureInfo.InvariantCulture),
@@ -1134,7 +1144,7 @@ namespace MediaBrowser.Controller.Entities
MediaStreams = MediaSourceManager.GetMediaStreams(item.Id), MediaStreams = MediaSourceManager.GetMediaStreams(item.Id),
MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id), MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id),
Name = GetMediaSourceName(item), Name = GetMediaSourceName(item),
Path = enablePathSubstitution ? GetMappedPath(item, item.Path, protocol) : item.Path, Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath,
RunTimeTicks = item.RunTimeTicks, RunTimeTicks = item.RunTimeTicks,
Container = item.Container, Container = item.Container,
Size = item.Size, Size = item.Size,
@@ -1162,11 +1172,18 @@ namespace MediaBrowser.Controller.Entities
info.Video3DFormat = video.Video3DFormat; info.Video3DFormat = video.Video3DFormat;
info.Timestamp = video.Timestamp; info.Timestamp = video.Timestamp;
if (video.IsShortcut) if (video.IsShortcut && !string.IsNullOrEmpty(video.ShortcutPath))
{
var shortcutProtocol = MediaSourceManager.GetPathProtocol(video.ShortcutPath);
// Only allow remote shortcut paths — local file paths in .strm files
// could be used to read arbitrary files from the server.
if (shortcutProtocol != MediaProtocol.File)
{ {
info.IsRemote = true; info.IsRemote = true;
info.Path = video.ShortcutPath; info.Path = video.ShortcutPath;
info.Protocol = MediaSourceManager.GetPathProtocol(info.Path); info.Protocol = shortcutProtocol;
}
} }
if (string.IsNullOrEmpty(info.Container)) if (string.IsNullOrEmpty(info.Container))
@@ -1610,12 +1627,17 @@ namespace MediaBrowser.Controller.Entities
return isAllowed; return isAllowed;
} }
if (maxAllowedSubRating is not null) if (!maxAllowedRating.HasValue)
{ {
return (ratingScore.SubScore ?? 0) <= maxAllowedSubRating && ratingScore.Score <= maxAllowedRating.Value; return true;
} }
return !maxAllowedRating.HasValue || ratingScore.Score <= maxAllowedRating.Value; if (ratingScore.Score != maxAllowedRating.Value)
{
return ratingScore.Score < maxAllowedRating.Value;
}
return !maxAllowedSubRating.HasValue || (ratingScore.SubScore ?? 0) <= maxAllowedSubRating.Value;
} }
public ParentalRatingScore GetParentalRatingScore() public ParentalRatingScore GetParentalRatingScore()
@@ -2038,6 +2060,9 @@ namespace MediaBrowser.Controller.Entities
public virtual async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken) public virtual async Task UpdateToRepositoryAsync(ItemUpdateType updateReason, CancellationToken cancellationToken)
=> await LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken).ConfigureAwait(false); => await LibraryManager.UpdateItemAsync(this, GetParent(), updateReason, cancellationToken).ConfigureAwait(false);
public async Task ReattachUserDataAsync(CancellationToken cancellationToken) =>
await LibraryManager.ReattachUserDataAsync(this, cancellationToken).ConfigureAwait(false);
/// <summary> /// <summary>
/// Validates that images within the item are still on the filesystem. /// Validates that images within the item are still on the filesystem.
/// </summary> /// </summary>

View File

@@ -452,6 +452,7 @@ namespace MediaBrowser.Controller.Entities
// That's all the new and changed ones - now see if any have been removed and need cleanup // That's all the new and changed ones - now see if any have been removed and need cleanup
var itemsRemoved = currentChildren.Values.Except(validChildren).ToList(); var itemsRemoved = currentChildren.Values.Except(validChildren).ToList();
var shouldRemove = !IsRoot || allowRemoveRoot; var shouldRemove = !IsRoot || allowRemoveRoot;
var actuallyRemoved = new List<BaseItem>();
// If it's an AggregateFolder, don't remove // If it's an AggregateFolder, don't remove
if (shouldRemove && itemsRemoved.Count > 0) if (shouldRemove && itemsRemoved.Count > 0)
{ {
@@ -467,6 +468,7 @@ namespace MediaBrowser.Controller.Entities
{ {
Logger.LogDebug("Removed item: {Path}", item.Path); Logger.LogDebug("Removed item: {Path}", item.Path);
actuallyRemoved.Add(item);
item.SetParent(null); item.SetParent(null);
LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false); LibraryManager.DeleteItem(item, new DeleteOptions { DeleteFileLocation = false }, this, false);
} }
@@ -477,6 +479,20 @@ namespace MediaBrowser.Controller.Entities
{ {
LibraryManager.CreateItems(newItems, this, cancellationToken); LibraryManager.CreateItems(newItems, this, cancellationToken);
} }
// After removing items, reattach any detached user data to remaining children
// that share the same user data keys (eg. same episode replaced with a new file).
if (actuallyRemoved.Count > 0)
{
var removedKeys = actuallyRemoved.SelectMany(i => i.GetUserDataKeys()).ToHashSet();
foreach (var child in validChildren)
{
if (child.GetUserDataKeys().Any(removedKeys.Contains))
{
await child.ReattachUserDataAsync(cancellationToken).ConfigureAwait(false);
}
}
}
} }
else else
{ {
@@ -715,14 +731,21 @@ namespace MediaBrowser.Controller.Entities
} }
else else
{ {
items = GetRecursiveChildren(user, query, out totalCount); // Save pagination params before clearing them to prevent pagination from happening
// before sorting. PostFilterAndSort will apply pagination after sorting.
var limit = query.Limit;
var startIndex = query.StartIndex;
query.Limit = null; query.Limit = null;
query.StartIndex = null; // override these here as they have already been applied query.StartIndex = null;
items = GetRecursiveChildren(user, query, out totalCount);
// Restore pagination params so PostFilterAndSort can apply them after sorting
query.Limit = limit;
query.StartIndex = startIndex;
} }
var result = PostFilterAndSort(items, query); return PostFilterAndSort(items, query);
result.TotalRecordCount = totalCount;
return result;
} }
if (this is not UserRootFolder if (this is not UserRootFolder
@@ -980,25 +1003,19 @@ namespace MediaBrowser.Controller.Entities
else else
{ {
// need to pass this param to the children. // need to pass this param to the children.
// Note: Don't pass Limit/StartIndex here as pagination should happen after sorting in PostFilterAndSort
var childQuery = new InternalItemsQuery var childQuery = new InternalItemsQuery
{ {
DisplayAlbumFolders = query.DisplayAlbumFolders, DisplayAlbumFolders = query.DisplayAlbumFolders,
Limit = query.Limit,
StartIndex = query.StartIndex,
NameStartsWith = query.NameStartsWith, NameStartsWith = query.NameStartsWith,
NameStartsWithOrGreater = query.NameStartsWithOrGreater, NameStartsWithOrGreater = query.NameStartsWithOrGreater,
NameLessThan = query.NameLessThan NameLessThan = query.NameLessThan
}; };
items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter); items = GetChildren(user, true, out totalItemCount, childQuery).Where(filter);
query.Limit = null;
query.StartIndex = null;
} }
var result = PostFilterAndSort(items, query); return PostFilterAndSort(items, query);
result.TotalRecordCount = totalItemCount;
return result;
} }
protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query) protected QueryResult<BaseItem> PostFilterAndSort(IEnumerable<BaseItem> items, InternalItemsQuery query)
@@ -1034,7 +1051,15 @@ namespace MediaBrowser.Controller.Entities
items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value); items = UserViewBuilder.FilterForAdjacency(items.ToList(), query.AdjacentTo.Value);
} }
return UserViewBuilder.SortAndPage(items, null, query, LibraryManager); var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList();
var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager);
if (query.EnableTotalRecordCount)
{
result.TotalRecordCount = filteredItems.Count;
}
return result;
} }
private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded( private static IEnumerable<BaseItem> CollapseBoxSetItemsIfNeeded(
@@ -1047,14 +1072,51 @@ namespace MediaBrowser.Controller.Entities
{ {
ArgumentNullException.ThrowIfNull(items); ArgumentNullException.ThrowIfNull(items);
if (CollapseBoxSetItems(query, queryParent, user, configurationManager)) if (!CollapseBoxSetItems(query, queryParent, user, configurationManager))
{ {
items = collectionManager.CollapseItemsWithinBoxSets(items, user); return items;
} }
var config = configurationManager.Configuration;
bool collapseMovies = config.EnableGroupingMoviesIntoCollections;
bool collapseSeries = config.EnableGroupingShowsIntoCollections;
if (user is null || (collapseMovies && collapseSeries))
{
return collectionManager.CollapseItemsWithinBoxSets(items, user);
}
if (!collapseMovies && !collapseSeries)
{
return items; return items;
} }
var collapsibleItems = new List<BaseItem>();
var remainingItems = new List<BaseItem>();
foreach (var item in items)
{
if ((collapseMovies && item is Movie) || (collapseSeries && item is Series))
{
collapsibleItems.Add(item);
}
else
{
remainingItems.Add(item);
}
}
if (collapsibleItems.Count == 0)
{
return remainingItems;
}
var collapsedItems = collectionManager.CollapseItemsWithinBoxSets(collapsibleItems, user);
return collapsedItems.Concat(remainingItems);
}
private static bool CollapseBoxSetItems( private static bool CollapseBoxSetItems(
InternalItemsQuery query, InternalItemsQuery query,
BaseItem queryParent, BaseItem queryParent,
@@ -1083,24 +1145,26 @@ namespace MediaBrowser.Controller.Entities
} }
var param = query.CollapseBoxSetItems; var param = query.CollapseBoxSetItems;
if (param.HasValue)
if (!param.HasValue)
{ {
if (user is not null && query.IncludeItemTypes.Any(type => return param.Value && AllowBoxSetCollapsing(query);
(type == BaseItemKind.Movie && !configurationManager.Configuration.EnableGroupingMoviesIntoCollections) ||
(type == BaseItemKind.Series && !configurationManager.Configuration.EnableGroupingShowsIntoCollections)))
{
return false;
} }
if (query.IncludeItemTypes.Length == 0 var config = configurationManager.Configuration;
|| query.IncludeItemTypes.Any(type => type == BaseItemKind.Movie || type == BaseItemKind.Series))
bool queryHasMovies = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Movie);
bool queryHasSeries = query.IncludeItemTypes.Length == 0 || query.IncludeItemTypes.Contains(BaseItemKind.Series);
bool collapseMovies = config.EnableGroupingMoviesIntoCollections;
bool collapseSeries = config.EnableGroupingShowsIntoCollections;
if (user is not null)
{ {
param = true; bool canCollapse = (queryHasMovies && collapseMovies) || (queryHasSeries && collapseSeries);
} return canCollapse && AllowBoxSetCollapsing(query);
} }
return param.HasValue && param.Value && AllowBoxSetCollapsing(query); return (queryHasMovies || queryHasSeries) && AllowBoxSetCollapsing(query);
} }
private static bool AllowBoxSetCollapsing(InternalItemsQuery request) private static bool AllowBoxSetCollapsing(InternalItemsQuery request)
@@ -1358,13 +1422,6 @@ namespace MediaBrowser.Controller.Entities
.Where(e => query is null || UserViewBuilder.FilterItem(e, query)) .Where(e => query is null || UserViewBuilder.FilterItem(e, query))
.ToArray(); .ToArray();
if (this is BoxSet && (query.OrderBy is null || query.OrderBy.Count == 0))
{
realChildren = realChildren
.OrderBy(e => e.ProductionYear ?? int.MaxValue)
.ToArray();
}
var childCount = realChildren.Length; var childCount = realChildren.Length;
if (result.Count < limit) if (result.Count < limit)
{ {

View File

@@ -125,6 +125,8 @@ namespace MediaBrowser.Controller.Entities
public string? Name { get; set; } public string? Name { get; set; }
public bool? UseRawName { get; set; }
public string? Person { get; set; } public string? Person { get; set; }
public Guid[] PersonIds { get; set; } public Guid[] PersonIds { get; set; }

View File

@@ -136,6 +136,12 @@ namespace MediaBrowser.Controller.Entities.Movies
return Sort(children, user).ToArray(); return Sort(children, user).ToArray();
} }
public override IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, out int totalItemCount, InternalItemsQuery query = null)
{
var children = base.GetChildren(user, includeLinkedChildren, out totalItemCount, query);
return Sort(children, user).ToArray();
}
public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount) public override IReadOnlyList<BaseItem> GetRecursiveChildren(User user, InternalItemsQuery query, out int totalCount)
{ {
var children = base.GetRecursiveChildren(user, query, out totalCount); var children = base.GetRecursiveChildren(user, query, out totalCount);

View File

@@ -201,12 +201,17 @@ namespace MediaBrowser.Controller.Entities.TV
public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes) public List<BaseItem> GetEpisodes(Series series, User user, IEnumerable<Episode> allSeriesEpisodes, DtoOptions options, bool shouldIncludeMissingEpisodes)
{ {
if (series is null)
{
return [];
}
return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes); return series.GetSeasonEpisodes(this, user, allSeriesEpisodes, options, shouldIncludeMissingEpisodes);
} }
public List<BaseItem> GetEpisodes() public List<BaseItem> GetEpisodes()
{ {
return Series.GetSeasonEpisodes(this, null, null, new DtoOptions(true), true); return GetEpisodes(Series, null, null, new DtoOptions(true), true);
} }
public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query) public override List<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)

View File

@@ -214,7 +214,7 @@ namespace MediaBrowser.Controller.Entities.TV
query.AncestorWithPresentationUniqueKey = null; query.AncestorWithPresentationUniqueKey = null;
query.SeriesPresentationUniqueKey = seriesKey; query.SeriesPresentationUniqueKey = seriesKey;
query.IncludeItemTypes = new[] { BaseItemKind.Season }; query.IncludeItemTypes = new[] { BaseItemKind.Season };
query.OrderBy = new[] { (ItemSortBy.IndexNumber, SortOrder.Ascending) }; query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
if (user is not null && !user.DisplayMissingEpisodes) if (user is not null && !user.DisplayMissingEpisodes)
{ {
@@ -247,6 +247,10 @@ namespace MediaBrowser.Controller.Entities.TV
query.AncestorWithPresentationUniqueKey = null; query.AncestorWithPresentationUniqueKey = null;
query.SeriesPresentationUniqueKey = seriesKey; query.SeriesPresentationUniqueKey = seriesKey;
if (query.OrderBy.Count == 0)
{
query.OrderBy = new[] { (ItemSortBy.SortName, SortOrder.Ascending) };
}
if (query.IncludeItemTypes.Length == 0) if (query.IncludeItemTypes.Length == 0)
{ {

View File

@@ -1,4 +1,5 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using MediaBrowser.Model.IO; using MediaBrowser.Model.IO;
@@ -61,4 +62,108 @@ public static class FileSystemHelper
} }
} }
} }
/// <summary>
/// Resolves a single link hop for the specified path.
/// </summary>
/// <remarks>
/// Returns <c>null</c> if the path is not a symbolic link or the filesystem does not support link resolution (e.g., exFAT).
/// </remarks>
/// <param name="path">The file path to resolve.</param>
/// <returns>
/// A <see cref="FileInfo"/> representing the next link target if the path is a link; otherwise, <c>null</c>.
/// </returns>
private static FileInfo? Resolve(string path)
{
try
{
return File.ResolveLinkTarget(path, returnFinalTarget: false) as FileInfo;
}
catch (IOException)
{
// Filesystem doesn't support links (e.g., exFAT).
return null;
}
}
/// <summary>
/// Gets the target of the specified file link.
/// </summary>
/// <remarks>
/// This helper exists because of this upstream runtime issue; https://github.com/dotnet/runtime/issues/92128.
/// </remarks>
/// <param name="linkPath">The path of the file link.</param>
/// <param name="returnFinalTarget">true to follow links to the final target; false to return the immediate next link.</param>
/// <returns>
/// A <see cref="FileInfo"/> if the <paramref name="linkPath"/> is a link, regardless of if the target exists; otherwise, <c>null</c>.
/// </returns>
public static FileInfo? ResolveLinkTarget(string linkPath, bool returnFinalTarget = false)
{
// Check if the file exists so the native resolve handler won't throw at us.
if (!File.Exists(linkPath))
{
return null;
}
if (!returnFinalTarget)
{
return Resolve(linkPath);
}
var targetInfo = Resolve(linkPath);
if (targetInfo is null || !targetInfo.Exists)
{
return targetInfo;
}
var currentPath = targetInfo.FullName;
var visited = new HashSet<string>(StringComparer.Ordinal) { linkPath, currentPath };
while (true)
{
var linkInfo = Resolve(currentPath);
if (linkInfo is null)
{
break;
}
var targetPath = linkInfo.FullName;
// If an infinite loop is detected, return the file info for the
// first link in the loop we encountered.
if (!visited.Add(targetPath))
{
return new FileInfo(targetPath);
}
targetInfo = linkInfo;
currentPath = targetPath;
// Exit if the target doesn't exist, so the native resolve handler won't throw at us.
if (!targetInfo.Exists)
{
break;
}
}
return targetInfo;
}
/// <summary>
/// Gets the target of the specified file link.
/// </summary>
/// <remarks>
/// This helper exists because of this upstream runtime issue; https://github.com/dotnet/runtime/issues/92128.
/// </remarks>
/// <param name="fileInfo">The file info of the file link.</param>
/// <param name="returnFinalTarget">true to follow links to the final target; false to return the immediate next link.</param>
/// <returns>
/// A <see cref="FileInfo"/> if the <paramref name="fileInfo"/> is a link, regardless of if the target exists; otherwise, <c>null</c>.
/// </returns>
public static FileInfo? ResolveLinkTarget(FileInfo fileInfo, bool returnFinalTarget = false)
{
ArgumentNullException.ThrowIfNull(fileInfo);
return ResolveLinkTarget(fileInfo.FullName, returnFinalTarget);
}
} }

View File

@@ -281,6 +281,14 @@ namespace MediaBrowser.Controller.Library
/// <returns>Returns a Task that can be awaited.</returns> /// <returns>Returns a Task that can be awaited.</returns>
Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken); Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken);
/// <summary>
/// Reattaches the user data to the item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous reattachment operation.</returns>
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Retrieves the item. /// Retrieves the item.
/// </summary> /// </summary>
@@ -652,5 +660,12 @@ namespace MediaBrowser.Controller.Library
/// This exists so plugins can trigger a library scan. /// This exists so plugins can trigger a library scan.
/// </remarks> /// </remarks>
void QueueLibraryScan(); void QueueLibraryScan();
/// <summary>
/// Add mblink file for a media path.
/// </summary>
/// <param name="virtualFolderPath">The path to the virtualfolder.</param>
/// <param name="pathInfo">The new virtualfolder.</param>
public void CreateShortcut(string virtualFolderPath, MediaPathInfo pathInfo);
} }
} }

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Diagnostics; using System.Diagnostics;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks; using System.Threading.Tasks;
using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Configuration;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
@@ -29,7 +30,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
/// </summary> /// </summary>
private readonly Lock _taskLock = new(); private readonly Lock _taskLock = new();
private readonly BlockingCollection<TaskQueueItem> _tasks = new(); private readonly Channel<TaskQueueItem> _tasks = Channel.CreateUnbounded<TaskQueueItem>();
private volatile int _workCounter; private volatile int _workCounter;
private Task? _cleanupTask; private Task? _cleanupTask;
@@ -77,7 +78,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
lock (_taskLock) lock (_taskLock)
{ {
if (_tasks.Count > 0 || _workCounter > 0) if (_tasks.Reader.Count > 0 || _workCounter > 0)
{ {
_logger.LogDebug("Delay cleanup task, operations still running."); _logger.LogDebug("Delay cleanup task, operations still running.");
// tasks are still there so its still in use. Reschedule cleanup task. // tasks are still there so its still in use. Reschedule cleanup task.
@@ -144,9 +145,9 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
_deadlockDetector.Value = stopToken.TaskStop; _deadlockDetector.Value = stopToken.TaskStop;
try try
{ {
foreach (var item in _tasks.GetConsumingEnumerable(stopToken.GlobalStop.Token)) while (!stopToken.GlobalStop.Token.IsCancellationRequested)
{ {
stopToken.GlobalStop.Token.ThrowIfCancellationRequested(); var item = await _tasks.Reader.ReadAsync(stopToken.GlobalStop.Token).ConfigureAwait(false);
try try
{ {
var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0; var newWorkerLimit = Interlocked.Increment(ref _workCounter) > 0;
@@ -242,7 +243,7 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
}; };
}).ToArray(); }).ToArray();
if (ShouldForceSequentialOperation()) if (ShouldForceSequentialOperation() || _deadlockDetector.Value is not null)
{ {
_logger.LogDebug("Process sequentially."); _logger.LogDebug("Process sequentially.");
try try
@@ -264,36 +265,15 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
for (var i = 0; i < workItems.Length; i++) for (var i = 0; i < workItems.Length; i++)
{ {
var item = workItems[i]!; var item = workItems[i]!;
_tasks.Add(item, CancellationToken.None); await _tasks.Writer.WriteAsync(item, CancellationToken.None).ConfigureAwait(false);
} }
if (_deadlockDetector.Value is not null)
{
_logger.LogDebug("Nested invocation detected, process in-place.");
try
{
// we are in a nested loop. There is no reason to spawn a task here as that would just lead to deadlocks and no additional concurrency is achieved
while (workItems.Any(e => !e.Done.Task.IsCompleted) && _tasks.TryTake(out var item, 200, _deadlockDetector.Value.Token))
{
await ProcessItem(item).ConfigureAwait(false);
}
}
catch (OperationCanceledException) when (_deadlockDetector.Value.IsCancellationRequested)
{
// operation is cancelled. Do nothing.
}
_logger.LogDebug("process in-place done.");
}
else
{
Worker(); Worker();
_logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length); _logger.LogDebug("Wait for {NoWorkers} to complete.", workItems.Length);
await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false); await Task.WhenAll([.. workItems.Select(f => f.Done.Task)]).ConfigureAwait(false);
_logger.LogDebug("{NoWorkers} completed.", workItems.Length); _logger.LogDebug("{NoWorkers} completed.", workItems.Length);
ScheduleTaskCleanup(); ScheduleTaskCleanup();
} }
}
/// <inheritdoc/> /// <inheritdoc/>
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
@@ -304,13 +284,12 @@ public sealed class LimitedConcurrencyLibraryScheduler : ILimitedConcurrencyLibr
} }
_disposed = true; _disposed = true;
_tasks.CompleteAdding(); _tasks.Writer.Complete();
foreach (var item in _taskRunners) foreach (var item in _taskRunners)
{ {
await item.Key.CancelAsync().ConfigureAwait(false); await item.Key.CancelAsync().ConfigureAwait(false);
} }
_tasks.Dispose();
if (_cleanupTask is not null) if (_cleanupTask is not null)
{ {
await _cleanupTask.ConfigureAwait(false); await _cleanupTask.ConfigureAwait(false);

View File

@@ -8,7 +8,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Controller</PackageId> <PackageId>Jellyfin.Controller</PackageId>
<VersionPrefix>10.11.0</VersionPrefix> <VersionPrefix>10.11.7</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>

View File

@@ -33,18 +33,18 @@ namespace MediaBrowser.Controller.MediaEncoding
public partial class EncodingHelper public partial class EncodingHelper
{ {
/// <summary> /// <summary>
/// The codec validation regex. /// The codec validation regex string.
/// This regular expression matches strings that consist of alphanumeric characters, hyphens, /// This regular expression matches strings that consist of alphanumeric characters, hyphens,
/// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters. /// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters.
/// This should matches all common valid codecs. /// This should matches all common valid codecs.
/// </summary> /// </summary>
public const string ContainerValidationRegex = @"^[a-zA-Z0-9\-\._,|]{0,40}$"; public const string ContainerValidationRegexStr = @"^[a-zA-Z0-9\-\._,|]{0,40}$";
/// <summary> /// <summary>
/// The level validation regex. /// The level validation regex string.
/// This regular expression matches strings representing a double. /// This regular expression matches strings representing a double.
/// </summary> /// </summary>
public const string LevelValidationRegex = @"-?[0-9]+(?:\.[0-9]+)?"; public const string LevelValidationRegexStr = @"-?[0-9]+(?:\.[0-9]+)?";
private const string _defaultMjpegEncoder = "mjpeg"; private const string _defaultMjpegEncoder = "mjpeg";
@@ -85,8 +85,7 @@ namespace MediaBrowser.Controller.MediaEncoding
private readonly Version _minFFmpegVaapiDeviceVendorId = new Version(7, 0, 1); private readonly Version _minFFmpegVaapiDeviceVendorId = new Version(7, 0, 1);
private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0); private readonly Version _minFFmpegQsvVppScaleModeOption = new Version(6, 0);
private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1); private readonly Version _minFFmpegRkmppHevcDecDoviRpu = new Version(7, 1, 1);
private readonly Version _minFFmpegReadrateCatchupOption = new Version(8, 0);
private static readonly Regex _containerValidationRegex = new(ContainerValidationRegex, RegexOptions.Compiled);
private static readonly string[] _videoProfilesH264 = private static readonly string[] _videoProfilesH264 =
[ [
@@ -180,6 +179,15 @@ namespace MediaBrowser.Controller.MediaEncoding
RemoveHdr10Plus, RemoveHdr10Plus,
} }
/// <summary>
/// The codec validation regex.
/// This regular expression matches strings that consist of alphanumeric characters, hyphens,
/// periods, underscores, commas, and vertical bars, with a length between 0 and 40 characters.
/// This should matches all common valid codecs.
/// </summary>
[GeneratedRegex(@"^[a-zA-Z0-9\-\._,|]{0,40}$")]
public static partial Regex ContainerValidationRegex();
[GeneratedRegex(@"\s+")] [GeneratedRegex(@"\s+")]
private static partial Regex WhiteSpaceRegex(); private static partial Regex WhiteSpaceRegex();
@@ -476,7 +484,7 @@ namespace MediaBrowser.Controller.MediaEncoding
return GetMjpegEncoder(state, encodingOptions); return GetMjpegEncoder(state, encodingOptions);
} }
if (_containerValidationRegex.IsMatch(codec)) if (ContainerValidationRegex().IsMatch(codec))
{ {
return codec.ToLowerInvariant(); return codec.ToLowerInvariant();
} }
@@ -517,7 +525,7 @@ namespace MediaBrowser.Controller.MediaEncoding
public static string GetInputFormat(string container) public static string GetInputFormat(string container)
{ {
if (string.IsNullOrEmpty(container) || !_containerValidationRegex.IsMatch(container)) if (string.IsNullOrEmpty(container) || !ContainerValidationRegex().IsMatch(container))
{ {
return null; return null;
} }
@@ -735,7 +743,7 @@ namespace MediaBrowser.Controller.MediaEncoding
{ {
var codec = state.OutputAudioCodec; var codec = state.OutputAudioCodec;
if (!_containerValidationRegex.IsMatch(codec)) if (!ContainerValidationRegex().IsMatch(codec))
{ {
codec = "aac"; codec = "aac";
} }
@@ -1267,6 +1275,20 @@ namespace MediaBrowser.Controller.MediaEncoding
} }
} }
// Use analyzeduration also for subtitle streams to improve resolution detection with streams inside MKS files
var analyzeDurationArgument = GetFfmpegAnalyzeDurationArg(state);
if (!string.IsNullOrEmpty(analyzeDurationArgument))
{
arg.Append(' ').Append(analyzeDurationArgument);
}
// Apply probesize, too, if configured
var ffmpegProbeSizeArgument = GetFfmpegProbesizeArg();
if (!string.IsNullOrEmpty(ffmpegProbeSizeArgument))
{
arg.Append(' ').Append(ffmpegProbeSizeArgument);
}
// Also seek the external subtitles stream. // Also seek the external subtitles stream.
var seekSubParam = GetFastSeekCommandLineParameter(state, options, segmentContainer); var seekSubParam = GetFastSeekCommandLineParameter(state, options, segmentContainer);
if (!string.IsNullOrEmpty(seekSubParam)) if (!string.IsNullOrEmpty(seekSubParam))
@@ -1768,8 +1790,11 @@ namespace MediaBrowser.Controller.MediaEncoding
public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level) public static string NormalizeTranscodingLevel(EncodingJobInfo state, string level)
{ {
if (double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel)) if (!double.TryParse(level, CultureInfo.InvariantCulture, out double requestLevel))
{ {
return null;
}
if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase)) if (string.Equals(state.ActualOutputVideoCodec, "av1", StringComparison.OrdinalIgnoreCase))
{ {
// Transcode to level 5.3 (15) and lower for maximum compatibility. // Transcode to level 5.3 (15) and lower for maximum compatibility.
@@ -1801,7 +1826,6 @@ namespace MediaBrowser.Controller.MediaEncoding
return "51"; return "51";
} }
} }
}
return level; return level;
} }
@@ -2189,12 +2213,10 @@ namespace MediaBrowser.Controller.MediaEncoding
} }
} }
var level = state.GetRequestedLevel(targetVideoCodec); var level = NormalizeTranscodingLevel(state, state.GetRequestedLevel(targetVideoCodec));
if (!string.IsNullOrEmpty(level)) if (!string.IsNullOrEmpty(level))
{ {
level = NormalizeTranscodingLevel(state, level);
// libx264, QSV, AMF can adjust the given level to match the output. // libx264, QSV, AMF can adjust the given level to match the output.
if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase) if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)
|| string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase)) || string.Equals(videoEncoder, "libx264", StringComparison.OrdinalIgnoreCase))
@@ -2378,6 +2400,13 @@ namespace MediaBrowser.Controller.MediaEncoding
var requestHasSDR = requestedRangeTypes.Contains(VideoRangeType.SDR.ToString(), StringComparison.OrdinalIgnoreCase); var requestHasSDR = requestedRangeTypes.Contains(VideoRangeType.SDR.ToString(), StringComparison.OrdinalIgnoreCase);
var requestHasDOVI = requestedRangeTypes.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase); var requestHasDOVI = requestedRangeTypes.Contains(VideoRangeType.DOVI.ToString(), StringComparison.OrdinalIgnoreCase);
// If SDR is the only supported range, we should not copy any of the HDR streams.
// All the following copy check assumes at least one HDR format is supported.
if (requestedRangeTypes.Length == 1 && requestHasSDR && videoStream.VideoRangeType != VideoRangeType.SDR)
{
return false;
}
// If the client does not support DOVI and the video stream is DOVI without fallback, we should not copy it. // If the client does not support DOVI and the video stream is DOVI without fallback, we should not copy it.
if (!requestHasDOVI && videoStream.VideoRangeType == VideoRangeType.DOVI) if (!requestHasDOVI && videoStream.VideoRangeType == VideoRangeType.DOVI)
{ {
@@ -2390,8 +2419,8 @@ namespace MediaBrowser.Controller.MediaEncoding
|| (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR) || (requestHasSDR && videoStream.VideoRangeType == VideoRangeType.DOVIWithSDR)
|| (requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.HDR10Plus))) || (requestHasHDR10 && videoStream.VideoRangeType == VideoRangeType.HDR10Plus)))
{ {
// If the video stream is in a static HDR format, don't allow copy if the client does not support HDR10 or HLG. // If the video stream is in HDR10+ or a static HDR format, don't allow copy if the client does not support HDR10 or HLG.
if (videoStream.VideoRangeType is VideoRangeType.HDR10 or VideoRangeType.HLG) if (videoStream.VideoRangeType is VideoRangeType.HDR10Plus or VideoRangeType.HDR10 or VideoRangeType.HLG)
{ {
return false; return false;
} }
@@ -5942,28 +5971,37 @@ namespace MediaBrowser.Controller.MediaEncoding
var isFullAfbcPipeline = isEncoderSupportAfbc && isDrmInDrmOut && !doOclTonemap; var isFullAfbcPipeline = isEncoderSupportAfbc && isDrmInDrmOut && !doOclTonemap;
var swapOutputWandH = doRkVppTranspose && swapWAndH; var swapOutputWandH = doRkVppTranspose && swapWAndH;
var outFormat = doOclTonemap ? "p010" : (isMjpegEncoder ? "bgra" : "nv12"); // RGA only support full range in rgb fmts var outFormat = doOclTonemap ? "p010" : "nv12";
var hwScaleFilter = GetHwScaleFilter("vpp", "rkrga", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); var hwScaleFilter = GetHwScaleFilter("vpp", "rkrga", outFormat, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH);
var doScaling = GetHwScaleFilter("vpp", "rkrga", string.Empty, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH); var doScaling = !string.IsNullOrEmpty(GetHwScaleFilter("vpp", "rkrga", string.Empty, swapOutputWandH, swpInW, swpInH, reqW, reqH, reqMaxW, reqMaxH));
if (!hasSubs if (!hasSubs
|| doRkVppTranspose || doRkVppTranspose
|| !isFullAfbcPipeline || !isFullAfbcPipeline
|| !string.IsNullOrEmpty(doScaling)) || doScaling)
{ {
var isScaleRatioSupported = IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 8.0f);
// RGA3 hardware only support (1/8 ~ 8) scaling in each blit operation, // RGA3 hardware only support (1/8 ~ 8) scaling in each blit operation,
// but in Trickplay there's a case: (3840/320 == 12), enable 2pass for it // but in Trickplay there's a case: (3840/320 == 12), enable 2pass for it
if (!string.IsNullOrEmpty(doScaling) if (doScaling && !isScaleRatioSupported)
&& !IsScaleRatioSupported(inW, inH, reqW, reqH, reqMaxW, reqMaxH, 8.0f))
{ {
// Vendor provided BSP kernel has an RGA driver bug that causes the output to be corrupted for P010 format. // Vendor provided BSP kernel has an RGA driver bug that causes the output to be corrupted for P010 format.
// Use NV15 instead of P010 to avoid the issue. // Use NV15 instead of P010 to avoid the issue.
// SDR inputs are using BGRA formats already which is not affected. // SDR inputs are using BGRA formats already which is not affected.
var intermediateFormat = string.Equals(outFormat, "p010", StringComparison.OrdinalIgnoreCase) ? "nv15" : outFormat; var intermediateFormat = doOclTonemap ? "nv15" : (isMjpegEncoder ? "bgra" : outFormat);
var hwScaleFilterFirstPass = $"scale_rkrga=w=iw/7.9:h=ih/7.9:format={intermediateFormat}:force_original_aspect_ratio=increase:force_divisible_by=4:afbc=1"; var hwScaleFilterFirstPass = $"scale_rkrga=w=iw/7.9:h=ih/7.9:format={intermediateFormat}:force_original_aspect_ratio=increase:force_divisible_by=4:afbc=1";
mainFilters.Add(hwScaleFilterFirstPass); mainFilters.Add(hwScaleFilterFirstPass);
} }
// The RKMPP MJPEG encoder on some newer chip models no longer supports RGB input.
// Use 2pass here to enable RGA output of full-range YUV in the 2nd pass.
if (isMjpegEncoder && !doOclTonemap && ((doScaling && isScaleRatioSupported) || !doScaling))
{
var hwScaleFilterFirstPass = "vpp_rkrga=format=bgra:afbc=1";
mainFilters.Add(hwScaleFilterFirstPass);
}
if (!string.IsNullOrEmpty(hwScaleFilter) && doRkVppTranspose) if (!string.IsNullOrEmpty(hwScaleFilter) && doRkVppTranspose)
{ {
hwScaleFilter += $":transpose={transposeDir}"; hwScaleFilter += $":transpose={transposeDir}";
@@ -6343,6 +6381,19 @@ namespace MediaBrowser.Controller.MediaEncoding
} }
} }
// Block unsupported H.264 Hi422P and Hi444PP profiles, which can be encoded with 4:2:0 pixel format
if (string.Equals(videoStream.Codec, "h264", StringComparison.OrdinalIgnoreCase)
&& ((videoStream.Profile?.Contains("4:2:2", StringComparison.OrdinalIgnoreCase) ?? false)
|| (videoStream.Profile?.Contains("4:4:4", StringComparison.OrdinalIgnoreCase) ?? false)))
{
// VideoToolbox on Apple Silicon has H.264 Hi444PP and theoretically also has Hi422P
if (!(hardwareAccelerationType == HardwareAccelerationType.videotoolbox
&& RuntimeInformation.OSArchitecture.Equals(Architecture.Arm64)))
{
return null;
}
}
var decoder = hardwareAccelerationType switch var decoder = hardwareAccelerationType switch
{ {
HardwareAccelerationType.vaapi => GetVaapiVidDecoder(state, options, videoStream, bitDepth), HardwareAccelerationType.vaapi => GetVaapiVidDecoder(state, options, videoStream, bitDepth),
@@ -7023,8 +7074,8 @@ namespace MediaBrowser.Controller.MediaEncoding
if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase)) if (string.Equals(videoStream.Codec, "av1", StringComparison.OrdinalIgnoreCase))
{ {
var accelType = GetHwaccelType(state, options, "av1", bitDepth, hwSurface); // there's an issue about AV1 AFBC on RK3588, disable it for now until it's fixed upstream
return accelType + ((!string.IsNullOrEmpty(accelType) && isAfbcSupported) ? " -afbc rga" : string.Empty); return GetHwaccelType(state, options, "av1", bitDepth, hwSurface);
} }
} }
@@ -7087,9 +7138,8 @@ namespace MediaBrowser.Controller.MediaEncoding
} }
} }
public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions, string segmentContainer) private string GetFfmpegAnalyzeDurationArg(EncodingJobInfo state)
{ {
var inputModifier = string.Empty;
var analyzeDurationArgument = string.Empty; var analyzeDurationArgument = string.Empty;
// Apply -analyzeduration as per the environment variable, // Apply -analyzeduration as per the environment variable,
@@ -7105,6 +7155,26 @@ namespace MediaBrowser.Controller.MediaEncoding
analyzeDurationArgument = "-analyzeduration " + ffmpegAnalyzeDuration; analyzeDurationArgument = "-analyzeduration " + ffmpegAnalyzeDuration;
} }
return analyzeDurationArgument;
}
private string GetFfmpegProbesizeArg()
{
var ffmpegProbeSize = _config.GetFFmpegProbeSize();
if (!string.IsNullOrEmpty(ffmpegProbeSize))
{
return $"-probesize {ffmpegProbeSize}";
}
return string.Empty;
}
public string GetInputModifier(EncodingJobInfo state, EncodingOptions encodingOptions, string segmentContainer)
{
var inputModifier = string.Empty;
var analyzeDurationArgument = GetFfmpegAnalyzeDurationArg(state);
if (!string.IsNullOrEmpty(analyzeDurationArgument)) if (!string.IsNullOrEmpty(analyzeDurationArgument))
{ {
inputModifier += " " + analyzeDurationArgument; inputModifier += " " + analyzeDurationArgument;
@@ -7113,11 +7183,11 @@ namespace MediaBrowser.Controller.MediaEncoding
inputModifier = inputModifier.Trim(); inputModifier = inputModifier.Trim();
// Apply -probesize if configured // Apply -probesize if configured
var ffmpegProbeSize = _config.GetFFmpegProbeSize(); var ffmpegProbeSizeArgument = GetFfmpegProbesizeArg();
if (!string.IsNullOrEmpty(ffmpegProbeSize)) if (!string.IsNullOrEmpty(ffmpegProbeSizeArgument))
{ {
inputModifier += $" -probesize {ffmpegProbeSize}"; inputModifier += " " + ffmpegProbeSizeArgument;
} }
var userAgentParam = GetUserAgentParam(state); var userAgentParam = GetUserAgentParam(state);
@@ -7157,8 +7227,10 @@ namespace MediaBrowser.Controller.MediaEncoding
inputModifier += GetVideoSyncOption(state.InputVideoSync, _mediaEncoder.EncoderVersion); inputModifier += GetVideoSyncOption(state.InputVideoSync, _mediaEncoder.EncoderVersion);
} }
int readrate = 0;
if (state.ReadInputAtNativeFramerate && state.InputProtocol != MediaProtocol.Rtsp) if (state.ReadInputAtNativeFramerate && state.InputProtocol != MediaProtocol.Rtsp)
{ {
readrate = 1;
inputModifier += " -re"; inputModifier += " -re";
} }
else if (encodingOptions.EnableSegmentDeletion else if (encodingOptions.EnableSegmentDeletion
@@ -7169,7 +7241,15 @@ namespace MediaBrowser.Controller.MediaEncoding
{ {
// Set an input read rate limit 10x for using SegmentDeletion with stream-copy // Set an input read rate limit 10x for using SegmentDeletion with stream-copy
// to prevent ffmpeg from exiting prematurely (due to fast drive) // to prevent ffmpeg from exiting prematurely (due to fast drive)
inputModifier += " -readrate 10"; readrate = 10;
inputModifier += $" -readrate {readrate}";
}
// Set a larger catchup value to revert to the old behavior,
// otherwise, remuxing might stall due to this new option
if (readrate > 0 && _mediaEncoder.EncoderVersion >= _minFFmpegReadrateCatchupOption)
{
inputModifier += $" -readrate_catchup {readrate * 100}";
} }
var flags = new List<string>(); var flags = new List<string>();

View File

@@ -35,6 +35,14 @@ public interface IItemRepository
void SaveImages(BaseItem item); void SaveImages(BaseItem item);
/// <summary>
/// Reattaches the user data to the item.
/// </summary>
/// <param name="item">The item.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task that represents the asynchronous reattachment operation.</returns>
Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken);
/// <summary> /// <summary>
/// Retrieves the item. /// Retrieves the item.
/// </summary> /// </summary>

View File

@@ -350,5 +350,12 @@ namespace MediaBrowser.Controller.Session
/// <param name="sessionIdOrPlaySessionId">The session id or playsession id.</param> /// <param name="sessionIdOrPlaySessionId">The session id or playsession id.</param>
/// <returns>Task.</returns> /// <returns>Task.</returns>
Task CloseLiveStreamIfNeededAsync(string liveStreamId, string sessionIdOrPlaySessionId); Task CloseLiveStreamIfNeededAsync(string liveStreamId, string sessionIdOrPlaySessionId);
/// <summary>
/// Gets the dto for session info.
/// </summary>
/// <param name="sessionInfo">The session info.</param>
/// <returns><see cref="SessionInfoDto"/> of the session.</returns>
SessionInfoDto ToSessionInfoDto(SessionInfo sessionInfo);
} }
} }

View File

@@ -1,3 +1,4 @@
using System;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using BDInfo.IO; using BDInfo.IO;
@@ -58,6 +59,8 @@ public class BdInfoDirectoryInfo : IDirectoryInfo
} }
} }
private static bool IsHidden(ReadOnlySpan<char> name) => name.StartsWith('.');
/// <summary> /// <summary>
/// Gets the directories. /// Gets the directories.
/// </summary> /// </summary>
@@ -65,6 +68,7 @@ public class BdInfoDirectoryInfo : IDirectoryInfo
public IDirectoryInfo[] GetDirectories() public IDirectoryInfo[] GetDirectories()
{ {
return _fileSystem.GetDirectories(_impl.FullName) return _fileSystem.GetDirectories(_impl.FullName)
.Where(d => !IsHidden(d.Name))
.Select(x => new BdInfoDirectoryInfo(_fileSystem, x)) .Select(x => new BdInfoDirectoryInfo(_fileSystem, x))
.ToArray(); .ToArray();
} }
@@ -76,6 +80,7 @@ public class BdInfoDirectoryInfo : IDirectoryInfo
public IFileInfo[] GetFiles() public IFileInfo[] GetFiles()
{ {
return _fileSystem.GetFiles(_impl.FullName) return _fileSystem.GetFiles(_impl.FullName)
.Where(d => !IsHidden(d.Name))
.Select(x => new BdInfoFileInfo(x)) .Select(x => new BdInfoFileInfo(x))
.ToArray(); .ToArray();
} }
@@ -88,6 +93,7 @@ public class BdInfoDirectoryInfo : IDirectoryInfo
public IFileInfo[] GetFiles(string searchPattern) public IFileInfo[] GetFiles(string searchPattern)
{ {
return _fileSystem.GetFiles(_impl.FullName, new[] { searchPattern }, false, false) return _fileSystem.GetFiles(_impl.FullName, new[] { searchPattern }, false, false)
.Where(d => !IsHidden(d.Name))
.Select(x => new BdInfoFileInfo(x)) .Select(x => new BdInfoFileInfo(x))
.ToArray(); .ToArray();
} }
@@ -105,6 +111,7 @@ public class BdInfoDirectoryInfo : IDirectoryInfo
new[] { searchPattern }, new[] { searchPattern },
false, false,
searchOption == SearchOption.AllDirectories) searchOption == SearchOption.AllDirectories)
.Where(d => !IsHidden(d.Name))
.Select(x => new BdInfoFileInfo(x)) .Select(x => new BdInfoFileInfo(x))
.ToArray(); .ToArray();
} }

View File

@@ -693,7 +693,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
[GeneratedRegex("^\\s\\S{6}\\s(?<codec>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)] [GeneratedRegex("^\\s\\S{6}\\s(?<codec>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
private static partial Regex CodecRegex(); private static partial Regex CodecRegex();
[GeneratedRegex("^\\s\\S{3}\\s(?<filter>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)] [GeneratedRegex("^\\s\\S{2,3}\\s(?<filter>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)]
private static partial Regex FilterRegex(); private static partial Regex FilterRegex();
} }
} }

View File

@@ -511,7 +511,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
? "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_chapters -show_format" ? "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_chapters -show_format"
: "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_format"; : "{0} -i {1} -threads {2} -v warning -print_format json -show_streams -show_format";
if (!isAudio && _proberSupportsFirstVideoFrame) if (protocol == MediaProtocol.File && !isAudio && _proberSupportsFirstVideoFrame)
{ {
args += " -show_frames -only_first_vframe"; args += " -show_frames -only_first_vframe";
} }
@@ -1122,7 +1122,15 @@ namespace MediaBrowser.MediaEncoding.Encoder
private void StartProcess(ProcessWrapper process) private void StartProcess(ProcessWrapper process)
{ {
process.Process.Start(); process.Process.Start();
try
{
process.Process.PriorityClass = ProcessPriorityClass.BelowNormal; process.Process.PriorityClass = ProcessPriorityClass.BelowNormal;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Unable to set process priority to BelowNormal for {ProcessFileName}", process.Process.StartInfo.FileName);
}
lock (_runningProcessesLock) lock (_runningProcessesLock)
{ {

View File

@@ -299,8 +299,11 @@ namespace MediaBrowser.MediaEncoding.Probing
// Handle WebM // Handle WebM
else if (string.Equals(splitFormat[i], "webm", StringComparison.OrdinalIgnoreCase)) else if (string.Equals(splitFormat[i], "webm", StringComparison.OrdinalIgnoreCase))
{ {
// Limit WebM to supported codecs // Limit WebM to supported stream types and codecs.
if (mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase)) // FFprobe can report "matroska,webm" for Matroska-like containers, so only keep "webm" if all streams are WebM-compatible.
// Any stream that is not video nor audio is not supported in WebM and should disqualify the webm container probe result.
if (mediaStreams.Any(stream => stream.Type is not MediaStreamType.Video and not MediaStreamType.Audio)
|| mediaStreams.Any(stream => (stream.Type == MediaStreamType.Video && !_webmVideoCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))
|| (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase)))) || (stream.Type == MediaStreamType.Audio && !_webmAudioCodecs.Contains(stream.Codec, StringComparison.OrdinalIgnoreCase))))
{ {
splitFormat[i] = string.Empty; splitFormat[i] = string.Empty;
@@ -853,7 +856,12 @@ namespace MediaBrowser.MediaEncoding.Probing
} }
// http://stackoverflow.com/questions/17353387/how-to-detect-anamorphic-video-with-ffprobe // http://stackoverflow.com/questions/17353387/how-to-detect-anamorphic-video-with-ffprobe
if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal)) if (string.IsNullOrEmpty(streamInfo.SampleAspectRatio)
&& string.IsNullOrEmpty(streamInfo.DisplayAspectRatio))
{
stream.IsAnamorphic = false;
}
else if (string.Equals(streamInfo.SampleAspectRatio, "1:1", StringComparison.Ordinal))
{ {
stream.IsAnamorphic = false; stream.IsAnamorphic = false;
} }
@@ -930,6 +938,15 @@ namespace MediaBrowser.MediaEncoding.Probing
{ {
stream.Rotation = data.Rotation; stream.Rotation = data.Rotation;
} }
// Parse video frame cropping metadata from side_data
// TODO: save them and make HW filters to apply them in HWA pipelines
else if (string.Equals(data.SideDataType, "Frame Cropping", StringComparison.OrdinalIgnoreCase))
{
// Streams containing artificially added frame cropping
// metadata should not be marked as anamorphic.
stream.IsAnamorphic = false;
}
} }
} }

View File

@@ -321,7 +321,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{ {
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
{ {
if (!File.Exists(outputPath)) if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{ {
await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false); await ConvertTextSubtitleToSrtInternal(subtitleStream, mediaSource, outputPath, cancellationToken).ConfigureAwait(false);
} }
@@ -423,9 +423,22 @@ namespace MediaBrowser.MediaEncoding.Subtitles
} }
} }
} }
else if (!File.Exists(outputPath)) else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{ {
failed = true; failed = true;
try
{
_logger.LogWarning("Deleting converted subtitle due to failure: {Path}", outputPath);
_fileSystem.DeleteFile(outputPath);
}
catch (FileNotFoundException)
{
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
}
} }
if (failed) if (failed)
@@ -499,7 +512,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false); var releaser = await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false);
if (File.Exists(outputPath)) if (File.Exists(outputPath) && _fileSystem.GetFileInfo(outputPath).Length > 0)
{ {
releaser.Dispose(); releaser.Dispose();
continue; continue;
@@ -556,7 +569,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputPaths = new List<string>(); var outputPaths = new List<string>();
var args = string.Format( var args = string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
"-i {0} -copyts", "-i {0}",
inputPath); inputPath);
foreach (var subtitleStream in subtitleStreams) foreach (var subtitleStream in subtitleStreams)
@@ -581,7 +594,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
outputPaths.Add(outputPath); outputPaths.Add(outputPath);
args += string.Format( args += string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
" -map 0:{0} -an -vn -c:s {1} \"{2}\"", " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
streamIndex, streamIndex,
outputCodec, outputCodec,
outputPath); outputPath);
@@ -600,7 +613,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputPaths = new List<string>(); var outputPaths = new List<string>();
var args = string.Format( var args = string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
"-i {0} -copyts", "-i {0}",
inputPath); inputPath);
foreach (var subtitleStream in subtitleStreams) foreach (var subtitleStream in subtitleStreams)
@@ -626,7 +639,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
outputPaths.Add(outputPath); outputPaths.Add(outputPath);
args += string.Format( args += string.Format(
CultureInfo.InvariantCulture, CultureInfo.InvariantCulture,
" -map 0:{0} -an -vn -c:s {1} \"{2}\"", " -map 0:{0} -an -vn -c:s {1} -flush_packets 1 \"{2}\"",
streamIndex, streamIndex,
outputCodec, outputCodec,
outputPath); outputPath);
@@ -713,10 +726,24 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{ {
foreach (var outputPath in outputPaths) foreach (var outputPath in outputPaths)
{ {
if (!File.Exists(outputPath)) if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{ {
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath); _logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
failed = true; failed = true;
try
{
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
_fileSystem.DeleteFile(outputPath);
}
catch (FileNotFoundException)
{
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
}
continue; continue;
} }
@@ -755,7 +782,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{ {
using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false)) using (await _semaphoreLocks.LockAsync(outputPath, cancellationToken).ConfigureAwait(false))
{ {
if (!File.Exists(outputPath)) if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{ {
var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream); var subtitleStreamIndex = EncodingHelper.FindIndex(mediaSource.MediaStreams, subtitleStream);
@@ -857,9 +884,22 @@ namespace MediaBrowser.MediaEncoding.Subtitles
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath); _logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
} }
} }
else if (!File.Exists(outputPath)) else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{ {
failed = true; failed = true;
try
{
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
_fileSystem.DeleteFile(outputPath);
}
catch (FileNotFoundException)
{
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
}
} }
if (failed) if (failed)

View File

@@ -396,7 +396,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath); ArgumentException.ThrowIfNullOrEmpty(_mediaEncoder.EncoderPath);
// If subtitles get burned in fonts may need to be extracted from the media file // If subtitles get burned in fonts may need to be extracted from the media file
if (state.SubtitleStream is not null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode) if (state.SubtitleStream is not null && (state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode || state.BaseRequest.AlwaysBurnInSubtitleWhenTranscoding))
{ {
if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay) if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay)
{ {

View File

@@ -8,7 +8,7 @@
<PropertyGroup> <PropertyGroup>
<Authors>Jellyfin Contributors</Authors> <Authors>Jellyfin Contributors</Authors>
<PackageId>Jellyfin.Model</PackageId> <PackageId>Jellyfin.Model</PackageId>
<VersionPrefix>10.11.0</VersionPrefix> <VersionPrefix>10.11.7</VersionPrefix>
<RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl> <RepositoryUrl>https://github.com/jellyfin/jellyfin</RepositoryUrl>
<PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression> <PackageLicenseExpression>GPL-3.0-only</PackageLicenseExpression>
</PropertyGroup> </PropertyGroup>

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