Compare commits

..

124 Commits

Author SHA1 Message Date
krvi
6c0eff5b39 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-07-18 21:48:35 +00:00
krvi
5e946a45ee Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-07-18 16:27:23 +00:00
CHO-HSUN-TE
3321a0cf09 Translated using Weblate (Chinese (Traditional Han script))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/zh_Hant/
2026-07-18 12:21:45 +00:00
Bond-009
dd0b273b26 Fix Swagger UI auth docs (#12990) (#16910) 2026-07-17 23:48:04 +02:00
Marc Brooks
cab108a839 Prevent ffmpeg from hanging extracting subtitles (#17297)
* Prevent ffmpeg from hanging extracting subtitles
Add `RunSubtitleExtractionProcess` to unify the external
_ffmpeg_ process handling and error management.
Add a `-nostdin` flag that prevents _ffmpeg_ from reading from
_stdin_ and blocking on an inherited stdin handle (e.g. when
Jellyfin runs as a service under NSSM), which otherwise hangs
subtitle extraction forever when _ffmpeg_ blocks on any
keyboard-interaction read until the timeout (30 minutes).
Close the redirected _stdin_ to ensure immediage EOF.
Drain the _stderr_ to a string and log it, to ensure we don't block
the _ffmpeg_ process on errors that exceed the pipe length.
Pass `-y` to _ffmpeg_ to ensure it overwrites any existing output file
without prompting for confirmation.

* Address review comments
Make sure we always drain stderr.
Make sure the timeout also honors the cancellationToken.
Make sure when we get cancelled we don't log it as a ffmpeg error.
2026-07-17 23:23:57 +02:00
Rant423
3d83e67a52 Show production companies under TV Shows' Studios (#17246)
* show production companies instead of networks

* keep both production companies and networks

* fix whitespace

* fix nullable type

* networks first, then production companies
2026-07-17 22:45:54 +02:00
Bond-009
b809d964b3 Merge pull request #17288 from nyanmisaka/ffmpeg-log-utf8
Fix potential garbled text in FFmpeg logs on Windows
2026-07-17 22:28:49 +02:00
Bond-009
96727072c8 Merge pull request #17327 from Shadowghost/remove-playbackpositionticks-mediasourceinfo
Remove PlaybackPositionTicks from MediaSourceInfo
2026-07-17 22:21:21 +02:00
Bond-009
9ec0d7c63b Merge pull request #17334 from nyanmisaka/fix-cuda-hwupload
Fix format negotiation in hybrid SW decode and CUDA tonemap pipeline
2026-07-17 22:15:49 +02:00
Bond-009
bfe0a47b32 Merge pull request #17151 from theguymadmax/fix-indentify-image
Fix Identify returning wrong images
2026-07-17 22:13:47 +02:00
Bond-009
9b5f830462 Merge pull request #17326 from theguymadmax/update-series-name
Update season and episode SeriesName when renaming a series
2026-07-17 22:06:26 +02:00
Bond-009
42f9ed76c0 Merge pull request #17280 from Shadowghost/remove-image-override-hack
Remove episode image override hack
2026-07-17 22:06:07 +02:00
Bond-009
69faa6c583 Merge pull request #17191 from IDisposable/fix/handler-path-traversal
Fix path transversal exposure in Plugins
2026-07-17 21:52:22 +02:00
Bond-009
a96dc8bd9b Merge pull request #17354 from jellyfin/renovate/actions-setup-dotnet-6.x 2026-07-17 13:56:55 +02:00
Bond-009
d146c347b4 Merge pull request #17359 from jellyfin/renovate/ci-deps 2026-07-17 13:54:14 +02:00
Bond-009
f2dea2a3fd Merge pull request #17348 from theguymadmax/revert-includeItemTypes-in-collectionType 2026-07-17 13:53:58 +02:00
Bond-009
4d1dd42420 Merge pull request #17343 from mbastian77/docs/channels-xml-docs 2026-07-17 13:53:27 +02:00
Bond-009
8eb0ba60c1 Merge pull request #17344 from mbastian77/docs/session-model-xml-docs 2026-07-17 13:52:59 +02:00
Bond-009
2bf15d95df Merge pull request #17340 from mbastian77/docs/dlna-model-xml-docs 2026-07-17 13:52:31 +02:00
Bond-009
be2db99586 Merge pull request #17339 from mbastian77/docs/providers-lookup-info-xml-docs 2026-07-17 13:51:59 +02:00
Bond-009
37fe9df27c Merge pull request #17338 from mbastian77/docs/deviceid-xml-docs 2026-07-17 13:51:29 +02:00
Fabián Sanhueza
8759ad4d49 Translated using Weblate (Spanish (Latin America))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/es_419/
2026-07-17 11:49:50 +00:00
Bond-009
051ff4cf6d Merge pull request #17337 from Shadowghost/limit-similar-items 2026-07-17 13:49:46 +02:00
theguymadmax
fa4626c080 Revert setting default BaseItemKind for CollectionType 2026-07-16 18:15:54 -04:00
renovate[bot]
4cc577a72d Update github/codeql-action action to v4.37.1 2026-07-16 18:47:00 +00:00
renovate[bot]
2b57a5725c Update actions/setup-dotnet action to v6 2026-07-16 07:02:47 +00:00
mbastian77
5cbafa566d Add XML docs to small session model types and remove CS1591 suppressions 2026-07-15 16:03:56 +02:00
mbastian77
0d3cf0169e Add XML docs to small channel types and remove CS1591 suppressions 2026-07-15 16:02:12 +02:00
Bond-009
f8771b52ec Merge pull request #17331 from jellyfin/renovate/dotnet-monorepo 2026-07-15 12:49:34 +02:00
Bond-009
e3d610c3c5 Merge pull request #17274 from theguymadmax/fix-max-login-attempts 2026-07-15 12:42:09 +02:00
Bond-009
486ffaa6dd Merge pull request #17248 from theguymadmax/add-novel 2026-07-15 12:40:17 +02:00
mbastian77
d96d1f1119 Add XML docs to small DLNA model types and remove CS1591 suppressions 2026-07-15 12:11:11 +02:00
mbastian77
30f28456de Add XML docs to lookup info types and remove CS1591 suppressions 2026-07-15 12:08:08 +02:00
mbastian77
f8aad322cc Add XML docs to DeviceId and remove CS1591 suppression 2026-07-15 11:58:29 +02:00
Shadowghost
c6545a8b68 Limit similar items to user accessible libraries 2026-07-15 11:47:59 +02:00
Bond-009
0fe9c1ce91 Merge pull request #17330 from jellyfin/renovate/microsoft 2026-07-15 11:42:52 +02:00
Bond-009
b6882c86dc Merge pull request #17250 from TaterTechStudios/feature/books-series-name-metadata 2026-07-15 07:57:57 +02:00
nyanmisaka
4503ad295c Fix format negotiation in hybrid SW decode and CUDA tonemap pipeline
The CUDA hwcontext in FFmpeg 8.1 has added support for 10bit
fully-planar formats, but few CUDA filters support them.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-07-15 13:54:45 +08:00
renovate[bot]
1a9bd2417a Update Microsoft 2026-07-14 20:11:54 +00:00
renovate[bot]
0d2f1b26b3 Update dependency dotnet-ef to v10.0.10 2026-07-14 20:11:43 +00:00
Bond-009
bc59e56ef5 Merge pull request #17266 from Shadowghost/fix-non-admin-additional-parts 2026-07-14 15:36:38 +02:00
Shadowghost
b8bac71270 remove PlaybackPositionTicks from MediaSourceInfo 2026-07-14 10:05:32 +02:00
theguymadmax
6238448716 Update season and episode SeriesName when renaming a series 2026-07-13 23:08:06 -04:00
Bond-009
cfeaef6180 Merge pull request #17306 from theguymadmax/fix-strm-sub-protocol 2026-07-13 20:14:20 +02:00
Bond-009
ebab369eac Merge pull request #17273 from theguymadmax/ratigs-numerical-score 2026-07-13 20:13:29 +02:00
DrummingBird1
0584a102ee Translated using Weblate (Hebrew)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/he/
2026-07-13 11:26:23 +00:00
theguymadmax
2aae53bc15 Apply review feedback 2026-07-12 11:54:56 -04:00
Bond-009
911044b415 Merge pull request #17287 from dkanada/audiobook-cover 2026-07-12 17:39:34 +02:00
Bond-009
b77def6883 Merge pull request #17268 from theguymadmax/fix-greece-ratings 2026-07-12 17:36:01 +02:00
Bond-009
5c48935fa8 Merge pull request #17220 from jellyfin/renovate/ci-deps 2026-07-12 17:33:36 +02:00
theguymadmax
e2d9d592bc Fix incorrect protocol used for subtitle charset detection 2026-07-11 17:33:06 -04:00
dkanada
305160d2e4 enable audiobook image saving to local folder 2026-07-11 11:00:46 +09:00
renovate[bot]
933f7eea1b Update CI dependencies 2026-07-10 07:47:01 +00:00
nyanmisaka
631a314d24 Fix potential garbled text in FFmpeg logs on Windows
Explicitly set StandardErrorEncoding and StandardOutputEncoding to
Encoding.UTF8 when invoking the FFmpeg subprocess.

This prevents log encoding issues and character corruption on Windows
environments that default to non-UTF8 ANSI code pages.

This fixes garbled metadata and font names in the FFmpeg logs.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-07-10 14:46:27 +08:00
dkanada
ed06fd5139 support external images for audiobooks 2026-07-10 14:19:45 +09:00
Shadowghost
38813f7d42 Cleanup PreferEpisodeParentPoster) 2026-07-09 12:07:13 +02:00
Shadowghost
853922443f Remove episode image override hack 2026-07-09 11:54:18 +02:00
theguymadmax
8b826d981b Fix max login attempts 2026-07-08 23:07:57 -04:00
theguymadmax
8bf1710e07 Check numeric rating value after splitting country code 2026-07-08 20:09:49 -04:00
theguymadmax
5e42941d3b Fix Greece parental ratings 2026-07-08 12:09:38 -04:00
theguymadmax
a1255bda6a Use FrozenSet 2026-07-08 11:45:16 -04:00
Shadowghost
9a2fdb3573 Fix additional parts for non-admins 2026-07-08 13:43:30 +02:00
Shed Shedson
53aafcd38e Translated using Weblate (Icelandic)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/is/
2026-07-07 18:12:36 +00:00
Shed Shedson
d73e65172a Translated using Weblate (Icelandic)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/is/
2026-07-07 16:33:35 +00:00
yuuta0331
974038e9b0 Translated using Weblate (Japanese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ja/
2026-07-07 02:20:57 +00:00
Jordan Rushing
f3fbe5575a Allow SeriesName to be editable from Item Metadata (books) 2026-07-06 15:28:49 -05:00
theguymadmax
860cb75d58 Add Novel job mapping to the Writing department 2026-07-06 11:27:24 -04:00
Lofuuzi
e31f168e9b Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/zh_Hant_HK/
2026-07-06 08:24:25 +00:00
Ulrik
7f5537ca47 Translated using Weblate (Danish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/da/
2026-07-06 05:12:54 +00:00
Cody Robibero
a03072b617 Merge pull request #16668 from johnpc/fix/people-images-not-displayed
Fix actor images not displayed until clicked
2026-07-05 16:25:38 -04:00
Cody Robibero
31d05dbdd6 Merge pull request #17228 from Shadowghost/fix-logout-concurrency
Don't throw on logout if session does not exist
2026-07-05 16:22:45 -04:00
Cody Robibero
d4faf7bd72 Merge pull request #17238 from iderex/fix/dateparse-format-provider
Use InvariantCulture when parsing machine-generated dates
2026-07-05 16:21:56 -04:00
Cody Robibero
6e728b009f Merge pull request #17044 from Shadowghost/version-model-and-handling
Fixes for multi version handling
2026-07-05 16:21:02 -04:00
Cody Robibero
efd3814a7e Merge pull request #17239 from theguymadmax/ratings-separator
Fix parental rating lookup for multi-rating entries
2026-07-05 16:20:45 -04:00
Bond-009
b391cd5e9e Merge pull request #17231 from theguymadmax/fix-paths-not-being-deleted
Fix ghost entries when deleting library paths
2026-07-05 16:19:46 +02:00
Bond-009
8433773fad Allow changing capitalization of usernames (#17229)
Fixes #17195
Adds a regression test
2026-07-05 15:59:14 +02:00
Lofuuzi
381bf18161 Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/zh_Hant_HK/
2026-07-05 08:08:46 +00:00
Nils Lehnen
f8ffccae7f Use InvariantCulture when parsing machine-generated dates
DateTime.TryParse without an IFormatProvider falls back to the current
thread culture, so the same string can parse differently (or fail)
depending on the server's locale. None of these call sites deal with
user-entered text - they parse dates that come from filenames, an
HTTP header, ffprobe metadata and values the app itself wrote to the
auth database - so InvariantCulture is the correct provider everywhere
here.

Fixes the S6580 / CA1305 warnings on these call sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-04 23:55:31 +02:00
theguymadmax
2528bc1032 Fix parental rating lookup for multi-rating entries 2026-07-04 17:53:55 -04:00
Ulrik
4b5f5d6ca3 Translated using Weblate (Danish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/da/
2026-07-04 21:25:02 +00:00
Cody Robibero
e2eaacd239 Merge pull request #17222 from theguymadmax/fix-folder-view
Fix folder view
2026-07-04 10:06:44 -04:00
Cody Robibero
77d4ab9ea9 Merge pull request #17225 from jellyfin/renovate/microsoft
Update Microsoft to 5.6.0
2026-07-04 10:06:11 -04:00
theguymadmax
43a152359e Fix ghost entries when deleting library paths 2026-07-03 14:12:56 -04:00
Bond_009
482cf4b8c3 Allow changing capitalization of usernames
Fixes #17195
Adds a regression test
2026-07-03 18:33:10 +02:00
Shadowghost
2b4945217a Don't throw on logout if session does not exist 2026-07-03 15:58:57 +02:00
Chamithu Mapalagama
ccc1712d10 Translated using Weblate (Sinhala)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/si/
2026-07-03 03:37:35 +00:00
renovate[bot]
820e7b91da Update Microsoft to 5.6.0 2026-07-02 23:57:21 +00:00
theguymadmax
08ba3717ea Fix folder view 2026-07-02 14:04:20 -04:00
Enea D'Angiò
8f3eb3205d Close sessions for lost WebSockets to prevent zombie SyncPlay groups (#17079)
Close sessions for lost WebSockets to prevent zombie SyncPlay groups
2026-07-02 19:36:48 +02:00
Bond-009
379c58a48d Merge pull request #17209 from theguymadmax/update-swedish-ratings
Fix Swedish rating
2026-07-02 19:29:12 +02:00
Bond-009
28b43838dd Merge pull request #17206 from zachhide/fix/livetv-hls-null-mediasource
Fix NullReferenceException in GetStreamingState for closed live streams
2026-07-02 19:29:06 +02:00
Shadowghost
38f1d9749e Fix review comments 2026-07-02 08:49:11 +02:00
m4st3r-0day
9cc25d133d Translated using Weblate (Albanian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/sq/
2026-07-01 20:34:37 +00:00
theguymadmax
8e1b69b37f Add missing Swedish ratings 2026-06-30 16:34:05 -04:00
Bond-009
a43fd188dc Merge pull request #17175 from obrenoalvim/fix/use-leftjoin-ef10
Use Enumerable.LeftJoin for activity log user query
2026-06-30 17:49:15 +02:00
Breno Alvim
f011529388 Use Enumerable.LeftJoin for activity log user query 2026-06-30 17:37:02 +02:00
zachhide
8ffb54603a Fix NullReferenceException in GetStreamingState for closed live streams
When a client polls the HLS playlist (e.g. live.m3u8) after a live stream has
been disposed because its consumer count dropped to zero,
GetLiveStreamWithDirectStreamProvider returns a null MediaSource. The live
branch of GetStreamingState then dereferenced it unconditionally, throwing a
NullReferenceException and returning HTTP 500 for every poll until the client
re-opens the stream. Guard against the null MediaSource and throw
ResourceNotFoundException so the request returns 404 instead of crashing.

Fixes #17009

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 01:13:51 -04:00
Bond-009
d3ee1e84b1 Merge pull request #17170 from Shadowghost/better-bitrates
Rework bitrate reporting
2026-06-29 18:06:14 +02:00
Bond-009
1035f6a101 Merge pull request #15954 from IDisposable/fix/books
Fix Book collections speed issues
2026-06-29 18:05:55 +02:00
John Corser
ef6f342a54 Use IItemTypeLookup and QueryPartitionHelpers
Address review feedback:
- Replace typeof(Person).FullName with IItemTypeLookup.BaseItemKindNames
- Replace foreach+ToListAsync with PartitionEagerAsync for batched
  iteration with built-in progress reporting
- Check HasImage/HasOverview on the loaded domain Person object
  instead of projecting from the DB query
2026-06-28 21:44:47 -04:00
John Corser
a888257c82 Project hasImage/hasOverview from DB query
Instead of re-checking image/overview on the domain object after loading,
project the values directly from the database query as part of the
anonymous type selection. This avoids redundant checks since the DB
already has this information.
2026-06-28 21:44:46 -04:00
John Corser
d55f808423 Move people filtering to database query
Instead of loading all people names and checking each one in memory,
query the database directly for Person items that need refresh:
- Missing primary image OR missing overview
- Not refreshed within the last 30 days

This reduces the operation from N+1 queries (1 for all names + 1 per
person to load) to a single filtered query returning only the IDs that
need work.
2026-06-28 21:44:46 -04:00
John Corser
dc92e3b0e4 Fix actor images not displayed until clicked
Move image refresh logic from PeopleValidator (which runs during library
scans) into PeopleValidationTask (the "Refresh People" scheduled task).
This keeps library scans fast while ensuring the scheduled task fetches
missing images from remote providers like TMDB.

People missing a Primary image or overview get refreshed with
MetadataRefreshMode.Default instead of ValidationOnly, with a 30-day
cooldown to avoid hammering providers for people they have no data for.

Fixes jellyfin#8103
2026-06-28 21:44:46 -04:00
Marc Brooks
95cebffa87 Add tests
Also fixed a sibling directory that matches the prefix.
2026-06-28 16:26:35 -05:00
Marc Brooks
617ebf367f Fix path transversal exposure in Plugins
The request path is not validated to a valid path and could allow escaping the transcode path and downloading of any arbitrary file in GetHlsPlaylistLegacy .

GetHlsAudioSegmentLegacy and GetHlsVideoSegmentLegacy have the same issue, and are NOT behind an Authorize so they are publicly exploitable.

Added a ValidateTranscodePath that verifies that requested file paths start with the transcode path setting. Also ensure that all filename comparisons are OrdinalIgnoreCase because we might be running on a filesystem where filename-casing doesn't have to match. Switched from InvariantCulture because the underlying OS filename comparisons are always byte-wise (with case insensitivity here).

Fixed a similar issue in GetPluginImage
2026-06-26 12:05:54 -05:00
Marc Brooks
70b4589382 Fix Book collections scanning all items
Added static method GetBaseItemKindsForCollectionType in ItemsController (moved from ContentFolderImageProvider to be shared)

Added AudioBook to GetRepresentativeItemTypes for CollectionType.books for consistency

Added GetBooks to GetUserItems for CollectionType.books which gets BaseItemKind.Book and BaseItemKind.AudioBook

Move GetBaseItemKindsForCollectionType to DtoExtensions

Cleaned up the missing null checks and used new collection expressions.
Associate Person to Book and AudioBook for related items.
2026-06-26 11:25:58 -05:00
Shadowghost
d090c59939 Rework bitrate reporting 2026-06-23 17:47:17 +02:00
theguymadmax
c49f279a27 Fix Identify returning wrong images 2026-06-21 18:22:00 -04:00
Shadowghost
0fb042b740 Surface the played version for resume 2026-06-19 21:51:57 +02:00
Shadowghost
a5706c2fa6 Reconcile Seasons and Episodes 2026-06-12 21:20:53 +02:00
Shadowghost
4278b34fe6 Merge remote-tracking branch 'upstream/master' into version-model-and-handling 2026-06-12 08:35:39 +02:00
Shadowghost
0aeee8233b Fix performance 2026-06-12 08:35:30 +02:00
Shadowghost
95de28cdda Merge remote-tracking branch 'upstream/master' into version-model-and-handling 2026-06-10 08:05:03 +02:00
Shadowghost
0874a26131 Coalesce alternate-version progress onto primary in resume filter 2026-06-09 23:23:03 +02:00
Shadowghost
fe1d8d8840 Collapse version groups to the primary version in queries 2026-06-07 23:06:14 +02:00
Shadowghost
a53a533cc4 Respect user permissions in version count 2026-06-07 23:06:14 +02:00
Shadowghost
4725722b1c Make ContinueWatching and NextUp version-aware 2026-06-07 23:06:14 +02:00
Shadowghost
9ea3f45886 Make resume queries version-aware 2026-06-07 23:06:14 +02:00
Shadowghost
e64cc73f88 Mark only linked alternate versions as grouped media sources 2026-06-07 23:06:14 +02:00
Shadowghost
63990d6a2d Surface extras across all versions 2026-06-07 23:06:14 +02:00
Shadowghost
09723bd123 Aggregate alternate versions via GetAllVersions 2026-06-07 23:06:14 +02:00
Shadowghost
c242533f4e Add version-aware playback tracking 2026-06-07 23:06:14 +02:00
Shadowghost
507998a4e3 Derive version-aware media source names 2026-06-07 23:06:14 +02:00
chasus
9569b4550d Fix Swagger UI auth docs (#12990) 2026-05-24 00:04:10 +10:00
118 changed files with 3554 additions and 587 deletions

View File

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

View File

@@ -27,18 +27,18 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Setup .NET
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1

View File

@@ -17,7 +17,7 @@ jobs:
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'
@@ -47,7 +47,7 @@ jobs:
fetch-depth: 0
- name: Setup .NET
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'

View File

@@ -17,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ env.SDK_VERSION }}

View File

@@ -22,7 +22,7 @@ jobs:
steps:
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ env.SDK_VERSION }}

View File

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

View File

@@ -28,7 +28,7 @@ jobs:
repository: ${{ inputs.repository }}
- name: Configure .NET
uses: actions/setup-dotnet@26b0ec14cb23fa6904739307f278c14f94c95bf1 # v5.4.0
uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: '10.0.x'

View File

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

View File

@@ -26,28 +26,28 @@
<PackageVersion Include="libse" Version="4.0.12" />
<PackageVersion Include="LrcParser" Version="2025.623.0" />
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.9" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.9" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.10" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.10" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.10" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Http" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Logging" Version="10.0.10" />
<PackageVersion Include="Microsoft.Extensions.Options" Version="10.0.10" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="MimeTypes" Version="2.5.2" />
<PackageVersion Include="Morestachio" Version="5.0.1.670" />
<PackageVersion Include="Moq" Version="4.18.4" />
@@ -77,7 +77,7 @@
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.3" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
<PackageVersion Include="System.Text.Json" Version="10.0.10" />
<PackageVersion Include="TagLibSharp" Version="2.3.0" />
<PackageVersion Include="z440.atl.core" Version="7.15.3" />
<PackageVersion Include="TMDbLib" Version="3.0.0" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -77,8 +77,8 @@
"TaskCleanLogsDescription": "自動刪走超過 {0} 日嘅紀錄檔。",
"TaskCleanLogs": "清理日誌資料夾",
"TaskRefreshLibrary": "掃描媒體櫃",
"TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節圖。",
"TaskRefreshChapterImages": "擷取章節圖",
"TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節預覽圖。",
"TaskRefreshChapterImages": "擷取章節圖",
"TaskCleanCacheDescription": "刪走系統已經唔再需要嘅快取檔案。",
"TaskCleanCache": "清理快取Cache資料夾",
"TasksChannelsCategory": "網路頻道",

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -234,7 +235,7 @@ public partial class AudioNormalizationTask : IScheduledTask
{
FileName = _mediaEncoder.EncoderPath,
Arguments = args,
RedirectStandardOutput = false,
StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true
},
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -34,7 +34,14 @@ public static class OrderMapper
(ItemSortBy.AirTime, _) => e => e.SortName,
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
(ItemSortBy.DatePlayed, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.LastPlayedDate,
(ItemSortBy.DatePlayed, not null) => e =>
jellyfinDbContext.UserData
.Where(w => w.UserId == query.User.Id && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
.Max(f => f.LastPlayedDate),
(ItemSortBy.DatePlayed, null) => e =>
jellyfinDbContext.UserData
.Where(w => w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id)
.Max(f => f.LastPlayedDate),
(ItemSortBy.PlayCount, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).FirstOrDefault()!.PlayCount,
(ItemSortBy.IsFavoriteOrLiked, _) => e => e.UserData!.Where(f => f.UserId.Equals(query.User!.Id)).OrderBy(f => f.CustomDataKey).Select(f => (bool?)f.IsFavorite).FirstOrDefault() ?? false,
(ItemSortBy.IsFolder, _) => e => e.IsFolder,

View File

@@ -170,7 +170,7 @@ namespace Jellyfin.Server.Implementations.Users
{
ThrowIfInvalidUsername(newName);
if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase))
if (oldName.Equals(newName, StringComparison.Ordinal))
{
throw new ArgumentException("The new and old names must be different.");
}
@@ -631,6 +631,7 @@ namespace Jellyfin.Server.Implementations.Users
if (maxInvalidLogins.HasValue && user.InvalidLoginAttemptCount >= maxInvalidLogins)
{
user.SetPermission(PermissionKind.IsDisabled, true);
dbContext.Update(user);
await dbContext.SaveChangesAsync()
.ConfigureAwait(false);
await _eventManager.PublishAsync(new UserLockedOutEventArgs(user)).ConfigureAwait(false);

View File

@@ -48,6 +48,7 @@ namespace Jellyfin.Server.Extensions
c.SwaggerEndpoint($"/{baseUrl}api-docs/openapi.json", "Jellyfin API");
c.InjectStylesheet($"/{baseUrl}api-docs/swagger/custom.css");
c.RoutePrefix = "api-docs/swagger";
c.UseRequestInterceptor("""(req) => { req.headers['Authorization'] = `MediaBrowser Token=\"${req.headers['Authorization']}\"`; return req; }""");
})
.UseReDoc(c =>
{

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
@@ -79,9 +80,9 @@ namespace Jellyfin.Server.Migrations.Routines
foreach (var row in authenticatedDevices)
{
var dateCreatedStr = row.GetString(9);
_ = DateTime.TryParse(dateCreatedStr, out var dateCreated);
_ = DateTime.TryParse(dateCreatedStr, CultureInfo.InvariantCulture, out var dateCreated);
var dateLastActivityStr = row.GetString(10);
_ = DateTime.TryParse(dateLastActivityStr, out var dateLastActivity);
_ = DateTime.TryParse(dateLastActivityStr, CultureInfo.InvariantCulture, out var dateLastActivity);
if (row.IsDBNull(6))
{

View File

@@ -1,19 +1,29 @@
#pragma warning disable CS1591
using System;
using System.Collections.Generic;
namespace MediaBrowser.Controller.Channels
{
/// <summary>
/// The result of a channel item query.
/// </summary>
public class ChannelItemResult
{
/// <summary>
/// Initializes a new instance of the <see cref="ChannelItemResult"/> class.
/// </summary>
public ChannelItemResult()
{
Items = Array.Empty<ChannelItemInfo>();
}
/// <summary>
/// Gets or sets the items.
/// </summary>
public IReadOnlyList<ChannelItemInfo> Items { get; set; }
/// <summary>
/// Gets or sets the total record count.
/// </summary>
public int? TotalRecordCount { get; set; }
}
}

View File

@@ -1,11 +1,18 @@
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Channels
{
/// <summary>
/// The type of a channel item.
/// </summary>
public enum ChannelItemType
{
/// <summary>
/// The item is a media item.
/// </summary>
Media = 0,
/// <summary>
/// The item is a folder.
/// </summary>
Folder = 1
}
}

View File

@@ -1,11 +1,15 @@
#nullable disable
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Channels
{
/// <summary>
/// The request for a latest media search in a channel.
/// </summary>
public class ChannelLatestMediaSearch
{
/// <summary>
/// Gets or sets the user id.
/// </summary>
public string UserId { get; set; }
}
}

View File

@@ -1,17 +1,33 @@
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Channels
{
/// <summary>
/// The parental rating of a channel.
/// </summary>
public enum ChannelParentalRating
{
/// <summary>
/// Suitable for a general audience.
/// </summary>
GeneralAudience = 0,
/// <summary>
/// Parental guidance suggested (US PG).
/// </summary>
UsPG = 1,
/// <summary>
/// Parents strongly cautioned (US PG-13).
/// </summary>
UsPG13 = 2,
/// <summary>
/// Restricted (US R).
/// </summary>
UsR = 3,
/// <summary>
/// Suitable for adults only.
/// </summary>
Adult = 4
}
}

View File

@@ -1,13 +1,20 @@
#nullable disable
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Channels
{
/// <summary>
/// The request for a search in a channel.
/// </summary>
public class ChannelSearchInfo
{
/// <summary>
/// Gets or sets the search term.
/// </summary>
public string SearchTerm { get; set; }
/// <summary>
/// Gets or sets the user id.
/// </summary>
public string UserId { get; set; }
}
}

View File

@@ -1,14 +1,15 @@
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Channels
{
/// <summary>
/// Interface for channels that provide a cache key.
/// </summary>
public interface IHasCacheKey
{
/// <summary>
/// Gets the cache key.
/// </summary>
/// <param name="userId">The user identifier.</param>
/// <returns>System.String.</returns>
/// <returns>The cache key.</returns>
string? GetCacheKey(string? userId);
}
}

View File

@@ -1,15 +1,27 @@
#pragma warning disable CS1591
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Channels
{
/// <summary>
/// Interface for channels that support deleting items.
/// </summary>
public interface ISupportsDelete
{
/// <summary>
/// Gets a value indicating whether the item can be deleted.
/// </summary>
/// <param name="item">The item.</param>
/// <returns><c>true</c> if the item can be deleted, <c>false</c> otherwise.</returns>
bool CanDelete(BaseItem item);
/// <summary>
/// Deletes the item with the provided id.
/// </summary>
/// <param name="id">The item id.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>A task representing the deletion of the item.</returns>
Task DeleteItem(string id, CancellationToken cancellationToken);
}
}

View File

@@ -1,11 +1,12 @@
#pragma warning disable CS1591
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
namespace MediaBrowser.Controller.Channels
{
/// <summary>
/// Interface for channels that support retrieving the latest media.
/// </summary>
public interface ISupportsLatestMedia
{
/// <summary>

View File

@@ -81,13 +81,6 @@ namespace MediaBrowser.Controller.Dto
/// </summary>
public bool AddCurrentProgram { get; set; }
/// <summary>
/// Gets or sets a value indicating whether an episode's portrait poster (its season's primary
/// image, falling back to the series') should replace the episode's own (16:9) primary image.
/// Used by views that render episodes as poster cards, e.g. "Latest".
/// </summary>
public bool PreferEpisodeParentPoster { get; set; }
/// <summary>
/// Gets a value indicating whether the specified field is populated.
/// </summary>

View File

@@ -87,6 +87,8 @@ namespace MediaBrowser.Controller.Entities
Model.Entities.ExtraType.Short
};
private static readonly char[] VersionDelimiters = ['-', '_', '.'];
private string _sortName;
private string _forcedSortName;
@@ -1099,8 +1101,9 @@ namespace MediaBrowser.Controller.Entities
}
}
var list = GetAllItemsForMediaSources();
var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType)).ToList();
var list = GetAllItemsForMediaSources().ToList();
var commonPrefix = GetCommonNamePrefix(list);
var result = list.Select(i => GetVersionInfo(enablePathSubstitution, i.Item, i.MediaSourceType, commonPrefix)).ToList();
if (IsActiveRecording())
{
@@ -1110,17 +1113,15 @@ namespace MediaBrowser.Controller.Entities
}
}
return result.OrderBy(i =>
{
if (i.VideoType == VideoType.VideoFile)
{
return 0;
}
// The source belonging to the item being queried sorts first so it is the default the client plays.
var selfId = Id.ToString("N", CultureInfo.InvariantCulture);
return 1;
}).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
.ThenByDescending(i => i, new MediaSourceWidthComparator())
.ToArray();
return result
.OrderByDescending(i => string.Equals(i.Id, selfId, StringComparison.OrdinalIgnoreCase))
.ThenBy(i => i.VideoType == VideoType.VideoFile ? 0 : 1)
.ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
.ThenByDescending(i => i, new MediaSourceWidthComparator())
.ToArray();
}
protected virtual IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
@@ -1128,7 +1129,7 @@ namespace MediaBrowser.Controller.Entities
return Enumerable.Empty<(BaseItem, MediaSourceType)>();
}
private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type)
private MediaSourceInfo GetVersionInfo(bool enablePathSubstitution, BaseItem item, MediaSourceType type, string commonPrefix = null)
{
ArgumentNullException.ThrowIfNull(item);
@@ -1141,7 +1142,7 @@ namespace MediaBrowser.Controller.Entities
Protocol = protocol ?? MediaProtocol.File,
MediaStreams = MediaSourceManager.GetMediaStreams(item.Id),
MediaAttachments = MediaSourceManager.GetMediaAttachments(item.Id),
Name = GetMediaSourceName(item),
Name = GetMediaSourceName(item, commonPrefix),
Path = enablePathSubstitution ? GetMappedPath(item, itemPath, protocol) : itemPath,
RunTimeTicks = item.RunTimeTicks,
Container = item.Container,
@@ -1220,7 +1221,7 @@ namespace MediaBrowser.Controller.Entities
return info;
}
internal string GetMediaSourceName(BaseItem item)
internal string GetMediaSourceName(BaseItem item, string commonPrefix = null)
{
var terms = new List<string>();
@@ -1228,12 +1229,31 @@ namespace MediaBrowser.Controller.Entities
if (item.IsFileProtocol && !string.IsNullOrEmpty(path))
{
var displayName = System.IO.Path.GetFileNameWithoutExtension(path);
if (HasLocalAlternateVersions)
// Prefer the suffix that differs from the other versions: strip the prefix shared by
// all sibling files. This works regardless of folder layout, so it also labels episode
// versions that share a season folder (e.g. "Greyscale" instead of the full
// "Show - S01E02 - Title - Greyscale"). The prefix is already retreated to a delimiter
// boundary (see GetCommonVersionPrefix).
if (!string.IsNullOrEmpty(commonPrefix)
&& displayName.Length > commonPrefix.Length
&& displayName.StartsWith(commonPrefix, StringComparison.OrdinalIgnoreCase))
{
var name = displayName.AsSpan(commonPrefix.Length).TrimStart([' ', .. VersionDelimiters]);
if (!name.IsWhiteSpace())
{
terms.Add(name.ToString());
}
}
// Fall back to the containing folder name (the common layout for movie versions, and
// the path taken when no common prefix could be derived).
if (terms.Count == 0 && HasLocalAlternateVersions)
{
var containingFolderName = System.IO.Path.GetFileName(ContainingFolderPath);
if (displayName.Length > containingFolderName.Length && displayName.StartsWith(containingFolderName, StringComparison.OrdinalIgnoreCase))
{
var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', '-']);
var name = displayName.AsSpan(containingFolderName.Length).TrimStart([' ', .. VersionDelimiters]);
if (!name.IsWhiteSpace())
{
terms.Add(name.ToString());
@@ -1290,6 +1310,98 @@ namespace MediaBrowser.Controller.Entities
return string.Join('/', terms);
}
/// <summary>
/// Derives the prefix shared by the supplied media source items' file names, used to strip the
/// common part and surface a short version label per source. Returns null when there are fewer
/// than two file-based sources, since there is nothing to differentiate.
/// </summary>
/// <param name="items">The media source items.</param>
/// <returns>The shared prefix, or null when no useful prefix exists.</returns>
private static string GetCommonNamePrefix(IReadOnlyList<(BaseItem Item, MediaSourceType MediaSourceType)> items)
{
var fileNames = new List<string>();
foreach (var (item, _) in items)
{
if (item.IsFileProtocol && !string.IsNullOrEmpty(item.Path))
{
fileNames.Add(System.IO.Path.GetFileNameWithoutExtension(item.Path));
}
}
if (fileNames.Count < 2)
{
return null;
}
var prefix = GetCommonVersionPrefix(fileNames);
return string.IsNullOrEmpty(prefix) ? null : prefix;
}
/// <summary>
/// Computes the case-insensitive longest common prefix of the supplied version file names,
/// retreated to the last delimiter boundary. Retreating keeps the differing suffix intact:
/// it avoids slicing through a word every version shares (e.g. "Grey" in "Greyscale" and
/// "Greyish") while still trimming the common part when every version is suffixed (e.g.
/// "- Greyscale" / "- Colorized"). It prefers a structural delimiter ('-', '_', '.') so a
/// token shared by the descriptors but separated only by spaces (e.g. a common "2160p ") is
/// kept in the label, falling back to a space only when no structural delimiter is shared. The
/// separators mirror the version delimiters recognised by the naming layer (Emby.Naming
/// VideoFlagDelimiters).
/// </summary>
/// <param name="fileNames">The version file names without extension; must contain at least one entry.</param>
/// <returns>The shared prefix retreated to a separator boundary, or an empty string when none is shared.</returns>
internal static string GetCommonVersionPrefix(IReadOnlyList<string> fileNames)
{
var prefix = fileNames[0];
for (var i = 1; i < fileNames.Count && prefix.Length > 0; i++)
{
var name = fileNames[i];
var length = Math.Min(prefix.Length, name.Length);
var common = 0;
while (common < length && char.ToUpperInvariant(prefix[common]) == char.ToUpperInvariant(name[common]))
{
common++;
}
prefix = prefix[..common];
}
// If the common prefix is itself a whole file name then one version is unlabelled (the
// base name); the boundary already sits at the end of that name, so don't retreat into it.
var prefixIsWholeName = false;
for (var i = 0; i < fileNames.Count; i++)
{
if (fileNames[i].Length == prefix.Length)
{
prefixIsWholeName = true;
break;
}
}
if (!prefixIsWholeName)
{
// Retreat to the last structural delimiter ('-', '_', '.').
var cut = prefix.Length;
while (cut > 0 && Array.IndexOf(VersionDelimiters, prefix[cut - 1]) < 0)
{
cut--;
}
if (cut == 0)
{
cut = prefix.Length;
while (cut > 0 && prefix[cut - 1] != ' ')
{
cut--;
}
}
prefix = prefix[..cut];
}
return prefix;
}
public Task RefreshMetadata(CancellationToken cancellationToken)
{
return RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(FileSystem)), cancellationToken);
@@ -2011,12 +2123,23 @@ namespace MediaBrowser.Controller.Entities
// I think it is okay to do this here.
// if this is only called when a user is manually forcing something to un-played
// then it probably is what we want to do...
ResetPlayedState(data);
UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
}
/// <summary>
/// Clears the played state on the supplied user data.
/// </summary>
/// <param name="data">The user data to reset.</param>
protected static void ResetPlayedState(UserItemData data)
{
ArgumentNullException.ThrowIfNull(data);
data.PlayCount = 0;
data.PlaybackPositionTicks = 0;
data.LastPlayedDate = null;
data.Played = false;
UserDataManager.SaveUserData(user, this, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
}
/// <summary>
@@ -2731,6 +2854,15 @@ namespace MediaBrowser.Controller.Entities
return LibraryManager.Sort(GetExtras(user).Where(e => e.ExtraType == Model.Entities.ExtraType.ThemeVideo), user, orderBy).ToArray();
}
/// <summary>
/// Gets the ids of the items whose owned extras belong to this item.
/// </summary>
/// <returns>An array containing the owner ids.</returns>
protected virtual Guid[] GetExtraOwnerIds()
{
return [Id];
}
/// <summary>
/// Get all extras associated with this item, sorted by <see cref="SortName"/>.
/// </summary>
@@ -2740,7 +2872,7 @@ namespace MediaBrowser.Controller.Entities
{
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
OwnerIds = [Id],
OwnerIds = GetExtraOwnerIds(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});
}
@@ -2755,7 +2887,7 @@ namespace MediaBrowser.Controller.Entities
{
return LibraryManager.GetItemList(new InternalItemsQuery(user)
{
OwnerIds = [Id],
OwnerIds = GetExtraOwnerIds(),
ExtraTypes = extraTypes.ToArray(),
OrderBy = [(ItemSortBy.SortName, SortOrder.Ascending)]
});

View File

@@ -384,6 +384,7 @@ namespace MediaBrowser.Controller.Entities
cancellationToken.ThrowIfCancellationRequested();
var validChildren = new List<BaseItem>();
var accessibleChildren = new List<BaseItem>();
var validChildrenNeedGeneration = false;
if (IsFileProtocol)
@@ -438,12 +439,19 @@ namespace MediaBrowser.Controller.Entities
{
if (!IsLibraryFolderAccessible(directoryService, child, allowRemoveRoot))
{
// Preserve inaccessible items so they aren't treated as removed.
if (currentChildren.TryGetValue(child.Id, out var childrenToKeep))
{
validChildren.Add(childrenToKeep);
}
continue;
}
if (currentChildren.TryGetValue(child.Id, out BaseItem currentChild))
{
validChildren.Add(currentChild);
accessibleChildren.Add(currentChild);
if (currentChild.UpdateFromResolvedItem(child) > ItemUpdateType.None)
{
@@ -480,11 +488,12 @@ namespace MediaBrowser.Controller.Entities
child.SetParent(this);
newItems.Add(child);
validChildren.Add(child);
accessibleChildren.Add(child);
}
// 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 shouldRemove = !IsRoot || allowRemoveRoot;
// If it's an AggregateFolder, don't remove
// Collect replaced primaries for deferred deletion (after CreateItems)
var replacedPrimaries = new List<(Video OldPrimary, Video NewPrimary)>();
@@ -497,7 +506,7 @@ namespace MediaBrowser.Controller.Entities
.Where(p => !string.IsNullOrEmpty(p))
.ToHashSet(StringComparer.OrdinalIgnoreCase);
if (shouldRemove && itemsRemoved.Count > 0)
if (itemsRemoved.Count > 0)
{
foreach (var item in itemsRemoved)
{
@@ -703,7 +712,7 @@ namespace MediaBrowser.Controller.Entities
validChildrenNeedGeneration = false;
}
await ValidateSubFolders(validChildren.OfType<Folder>().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false);
await ValidateSubFolders(accessibleChildren.OfType<Folder>().ToList(), directoryService, innerProgress, cancellationToken).ConfigureAwait(false);
}
if (refreshChildMetadata)
@@ -742,7 +751,7 @@ namespace MediaBrowser.Controller.Entities
validChildren = Children.ToList();
}
await RefreshMetadataRecursive(validChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false);
await RefreshMetadataRecursive(accessibleChildren, refreshOptions, recursive, innerProgress, cancellationToken).ConfigureAwait(false);
}
}
}

View File

@@ -61,6 +61,9 @@ namespace MediaBrowser.Controller.Entities
case CollectionType.folders:
return GetResult(_libraryManager.GetUserRootFolder().GetChildren(user, true), query);
case CollectionType.books:
return GetBooks(queryParent, user, query);
case CollectionType.tvshows:
return GetTvView(queryParent, user, query);
@@ -190,6 +193,17 @@ namespace MediaBrowser.Controller.Entities
return _libraryManager.GetItemsResult(query);
}
private QueryResult<BaseItem> GetBooks(Folder parent, User user, InternalItemsQuery query)
{
query.Recursive = true;
query.Parent = parent;
query.SetUser(user);
query.IncludeItemTypes = new[] { BaseItemKind.Book, BaseItemKind.AudioBook };
return _libraryManager.GetItemsResult(query);
}
private QueryResult<BaseItem> GetMovieMovies(Folder parent, User user, InternalItemsQuery query)
{
query.Recursive = true;

View File

@@ -34,11 +34,11 @@ namespace MediaBrowser.Controller.Entities
{
public Video()
{
AdditionalParts = Array.Empty<string>();
LocalAlternateVersions = Array.Empty<string>();
SubtitleFiles = Array.Empty<string>();
AudioFiles = Array.Empty<string>();
LinkedAlternateVersions = Array.Empty<LinkedChild>();
AdditionalParts = [];
LocalAlternateVersions = [];
SubtitleFiles = [];
AudioFiles = [];
LinkedAlternateVersions = [];
}
[JsonIgnore]
@@ -254,7 +254,7 @@ namespace MediaBrowser.Controller.Entities
private int GetMediaSourceCount(HashSet<Guid> callstack = null)
{
callstack ??= new();
callstack ??= [];
if (PrimaryVersionId.HasValue)
{
var item = LibraryManager.GetItemById(PrimaryVersionId.Value);
@@ -335,6 +335,102 @@ namespace MediaBrowser.Controller.Entities
PresentationUniqueKey = CreatePresentationUniqueKey();
}
/// <summary>
/// Marks the played status of this video and propagates it to its alternate versions.
/// </summary>
/// <param name="user">The user.</param>
/// <param name="datePlayed">The date played.</param>
/// <param name="resetPosition">if set to <c>true</c> [reset position].</param>
public override void MarkPlayed(User user, DateTime? datePlayed, bool resetPosition)
{
base.MarkPlayed(user, datePlayed, resetPosition);
PropagatePlayedState(user, true, resetPosition);
}
/// <summary>
/// Marks this video unplayed and propagates the change to its alternate versions.
/// </summary>
/// <param name="user">The user.</param>
public override void MarkUnplayed(User user)
{
base.MarkUnplayed(user);
// MarkUnplayed always clears the position on this video, so reset the versions too.
PropagatePlayedState(user, false, true);
}
/// <summary>
/// Propagates the played status to every alternate version of this video.
/// </summary>
/// <param name="user">The user.</param>
/// <param name="played">The played status to apply to the alternate versions.</param>
/// <param name="resetPosition">When marking played, controls whether each version's resume point
/// is also reset (<c>true</c>) or left untouched (<c>false</c>). Ignored when marking unplayed,
/// which always fully resets every version.</param>
public void PropagatePlayedState(User user, bool played, bool resetPosition = true)
{
ArgumentNullException.ThrowIfNull(user);
if (!PrimaryVersionId.HasValue && LinkedAlternateVersions.Length == 0 && !HasLocalAlternateVersions)
{
return;
}
foreach (var (item, _) in GetAllItemsForMediaSources())
{
if (item.Id.Equals(Id) || item is not Video)
{
continue;
}
if (played)
{
var dto = new UpdateUserItemDataDto { Played = true };
if (resetPosition)
{
dto.PlaybackPositionTicks = 0;
}
// SaveUserData only writes the fields set on the DTO, so play count and other state are preserved.
UserDataManager.SaveUserData(user, item, dto, UserDataSaveReason.TogglePlayed);
}
else
{
var data = UserDataManager.GetUserData(user, item);
if (data is null)
{
continue;
}
ResetPlayedState(data);
UserDataManager.SaveUserData(user, item, data, UserDataSaveReason.TogglePlayed, CancellationToken.None);
}
}
}
/// <summary>
/// Gets this video together with all of its alternate versions (local and linked and, when this
/// is itself an alternate, the primary and the primary's other versions), deduplicated.
/// </summary>
/// <returns>This video and every alternate version of it.</returns>
public IReadOnlyList<Video> GetAllVersions()
{
return GetAllItemsForMediaSources()
.Select(i => i.Item)
.OfType<Video>()
.ToList();
}
/// <summary>
/// Gets the alternate version of this video that matches the supplied item id.
/// </summary>
/// <param name="itemId">The version item id (the playback media source id).</param>
/// <returns>The matching version, or <c>null</c> when the id is not a version of this video.</returns>
public Video GetAlternateVersion(Guid itemId)
{
return GetAllVersions().FirstOrDefault(i => i.Id.Equals(itemId));
}
public override string CreatePresentationUniqueKey()
{
if (PrimaryVersionId.HasValue)
@@ -396,8 +492,8 @@ namespace MediaBrowser.Controller.Entities
public IOrderedEnumerable<Video> GetAdditionalParts(User user = null)
{
return GetAdditionalPartIds()
.Select(i => LibraryManager.GetItemById<Video>(i, user))
.Where(i => i is not null)
.Select(i => LibraryManager.GetItemById<Video>(i))
.Where(i => i is not null && (user is null || i.IsVisible(user)))
.OrderBy(i => i.SortName);
}
@@ -642,39 +738,58 @@ namespace MediaBrowser.Controller.Entities
}).FirstOrDefault();
}
/// <summary>
/// Gets the ids of the items whose owned extras belong to this item.
/// Extras are linked to a single version but need tp be surfaced for all versions.
/// </summary>
/// <returns>An array containing the owner ids.</returns>
protected override Guid[] GetExtraOwnerIds()
{
return GetAllItemsForMediaSources()
.Select(i => i.Item.Id)
.Distinct()
.ToArray();
}
protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
{
var list = new List<(BaseItem, MediaSourceType)>
{
(this, MediaSourceType.Default)
};
var primary = PrimaryVersionId.HasValue
? LibraryManager.GetItemById(PrimaryVersionId.Value) as Video
: null;
list.AddRange(
LibraryManager.GetLinkedAlternateVersions(this)
.Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
var primaryLinked = primary is null
? []
: LibraryManager.GetLinkedAlternateVersions(primary).ToList();
if (PrimaryVersionId.HasValue)
{
if (LibraryManager.GetItemById(PrimaryVersionId.Value) is Video primary)
{
var existingIds = list.Select(i => i.Item1.Id).ToList();
list.Add((primary, MediaSourceType.Grouping));
list.AddRange(LibraryManager.GetLinkedAlternateVersions(primary).Where(i => !existingIds.Contains(i.Id)).Select(i => ((BaseItem)i, MediaSourceType.Grouping)));
}
}
// Grouping marks user-merged (splittable) sources. The primary is only such a source when
// this video is linked onto it; for local (file-based) alternates the primary is just
// another default source.
var primaryType = primaryLinked.Any(i => i.Id.Equals(Id))
? MediaSourceType.Grouping
: MediaSourceType.Default;
var localAlternates = list
.SelectMany(i =>
{
return i.Item1 is Video video ? LibraryManager.GetLocalAlternateVersionIds(video) : Enumerable.Empty<Guid>();
})
.Select(LibraryManager.GetItemById)
.Where(i => i is not null)
// This video and its linked alternates, when this is itself an alternate, the primary and the primary's linked alternates.
var grouped = new[] { ((BaseItem)this, MediaSourceType.Default) }
.Concat(LibraryManager.GetLinkedAlternateVersions(this).Select(i => ((BaseItem)i, MediaSourceType.Grouping)))
.Concat(primary is null
? []
: primaryLinked.Select(i => ((BaseItem)i, MediaSourceType.Grouping)).Prepend(((BaseItem)primary, primaryType)))
.ToList();
list.AddRange(localAlternates.Select(i => (i, MediaSourceType.Default)));
// The local (file-based) alternate versions of every grouped item.
var localAlternates = grouped
.Select(i => i.Item1)
.OfType<Video>()
.SelectMany(LibraryManager.GetLocalAlternateVersionIds)
.Select(LibraryManager.GetItemById)
.Where(i => i is not null)
.Select(i => (i, MediaSourceType.Default));
return list;
// Deduplicate
return grouped
.Concat(localAlternates)
.DistinctBy(i => i.Item1.Id)
.ToList();
}
}
}

View File

@@ -62,6 +62,24 @@ namespace MediaBrowser.Controller.Library
/// <returns>A dictionary mapping item IDs to their user data.</returns>
Dictionary<Guid, UserItemData> GetUserDataBatch(IReadOnlyList<BaseItem> items, User user);
/// <summary>
/// Gets the user data that should drive resume for a multi-version item: the data of the most
/// recently played alternate version (including the item itself) that has a resume point.
/// </summary>
/// <param name="user">The user.</param>
/// <param name="item">The item.</param>
/// <returns>The resume version's data, or <c>null</c> when the item has no versions or none has a resume point.</returns>
VersionResumeData? GetResumeUserData(User user, BaseItem item);
/// <summary>
/// Gets the resume-driving user data for multiple items in a single batch operation.
/// See <see cref="GetResumeUserData(User, BaseItem)"/>.
/// </summary>
/// <param name="items">The items to get resume data for.</param>
/// <param name="user">The user.</param>
/// <returns>A dictionary mapping item ids to their resume version's data; items without one are omitted.</returns>
IReadOnlyDictionary<Guid, VersionResumeData> GetResumeUserDataBatch(IReadOnlyList<BaseItem> items, User user);
/// <summary>
/// Gets the user data dto.
/// </summary>
@@ -80,5 +98,13 @@ namespace MediaBrowser.Controller.Library
/// <param name="reportedPositionTicks">New playstate.</param>
/// <returns>True if playstate was updated.</returns>
bool UpdatePlayState(BaseItem item, UserItemData data, long? reportedPositionTicks);
/// <summary>
/// Clears any stored audio and subtitle stream selections for the given user/item pair.
/// Used when the user has opted out of remembering selections.
/// </summary>
/// <param name="user">The user.</param>
/// <param name="item">The item.</param>
void ResetPlaybackStreamSelections(User user, BaseItem item);
}
}

View File

@@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using MediaBrowser.Controller.Entities;
namespace MediaBrowser.Controller.Library
{
/// <summary>
/// Single definition of "which alternate version was most recently played" shared by the resume tile
/// (<see cref="IUserDataManager.GetResumeUserData"/>), the media-source default ordering and Next Up.
/// Each call site declares its own eligibility rule so the intentional differences (resumable-only vs.
/// resumable-or-completed) are visible in one place instead of being re-implemented divergently.
/// The SQL resume query keeps its own translation of the same rule.
/// </summary>
public static class VersionPlaybackSelector
{
/// <summary>
/// Selects the entry whose user data has the greatest <see cref="UserItemData.LastPlayedDate"/>,
/// considering only entries that satisfy <paramref name="isEligible"/>. On an exact tie the first
/// encountered entry wins.
/// </summary>
/// <typeparam name="T">The candidate type (e.g. a version item or a media source).</typeparam>
/// <param name="items">The candidates to choose from.</param>
/// <param name="dataSelector">Resolves the user data for a candidate, or <c>null</c> when it has none.</param>
/// <param name="isEligible">Whether a candidate's user data makes it a valid winner.</param>
/// <returns>The most recently played eligible candidate, or <c>default</c> when none qualify.</returns>
public static T? SelectMostRecentlyPlayed<T>(
IEnumerable<T> items,
Func<T, UserItemData?> dataSelector,
Func<UserItemData, bool> isEligible)
{
ArgumentNullException.ThrowIfNull(items);
ArgumentNullException.ThrowIfNull(dataSelector);
ArgumentNullException.ThrowIfNull(isEligible);
T? winner = default;
var winnerDate = DateTime.MinValue;
var hasWinner = false;
foreach (var item in items)
{
var data = dataSelector(item);
if (data is null || !isEligible(data))
{
continue;
}
var date = data.LastPlayedDate ?? DateTime.MinValue;
if (!hasWinner || date > winnerDate)
{
winner = item;
winnerDate = date;
hasWinner = true;
}
}
return winner;
}
}
}

View File

@@ -0,0 +1,41 @@
using System;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
namespace MediaBrowser.Controller.Library
{
/// <summary>
/// The user data of the most recently played alternate version that should drive the completion state of a multi-version item.
/// </summary>
/// <param name="VersionId">The id of the version that owns <paramref name="UserData"/>.</param>
/// <param name="UserData">The resume version's user data.</param>
public record VersionResumeData(Guid VersionId, UserItemData UserData)
{
/// <summary>
/// Merges the most recently played version's completion state into the supplied user data dto.
/// Completion (played) propagates to the primary. An in-progress resume position stays on the version
/// that owns it, which is surfaced directly (e.g. in resume queries) so that playback always targets
/// the correct version rather than resuming the primary at another version's offset. When the movie was
/// finished on a different version, the primary's own stale resume position is cleared so it does not
/// render as "watched and resumable" at the same time.
/// </summary>
/// <param name="dto">The user data dto to update.</param>
public void ApplyTo(UserItemDataDto dto)
{
dto.Played = dto.Played || UserData.Played;
if ((UserData.LastPlayedDate ?? DateTime.MinValue) > (dto.LastPlayedDate ?? DateTime.MinValue))
{
dto.LastPlayedDate = UserData.LastPlayedDate;
}
// A different version was finished (played, no resume position of its own) and is the most
// recently played: the whole movie is watched.
if (!VersionId.Equals(dto.ItemId) && UserData.Played && UserData.PlaybackPositionTicks <= 0)
{
dto.PlaybackPositionTicks = 0;
dto.PlayedPercentage = null;
}
}
}
}

View File

@@ -4054,7 +4054,7 @@ namespace MediaBrowser.Controller.MediaEncoding
mainFilters.Add(swDeintFilter);
}
var outFormat = doCuTonemap ? "yuv420p10le" : "yuv420p";
var outFormat = doCuTonemap ? "p010le" : "yuv420p";
var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, swpInW, swpInH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH);
// sw scale
mainFilters.Add(swScaleFilter);

View File

@@ -1,11 +1,15 @@
#nullable disable
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Providers
{
/// <summary>
/// The lookup info for books.
/// </summary>
public class BookInfo : ItemLookupInfo
{
/// <summary>
/// Gets or sets the name of the series the book belongs to.
/// </summary>
public string SeriesName { get; set; }
}
}

View File

@@ -1,7 +1,8 @@
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Providers
{
/// <summary>
/// The lookup info for box sets.
/// </summary>
public class BoxSetInfo : ItemLookupInfo
{
}

View File

@@ -1,10 +1,16 @@
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Providers
{
/// <summary>
/// Interface for items that provide lookup info for metadata providers.
/// </summary>
/// <typeparam name="TLookupInfoType">The type of the lookup info.</typeparam>
public interface IHasLookupInfo<out TLookupInfoType>
where TLookupInfoType : ItemLookupInfo, new()
{
/// <summary>
/// Gets the lookup info.
/// </summary>
/// <returns>The lookup info.</returns>
TLookupInfoType GetLookupInfo();
}
}

View File

@@ -1,7 +1,8 @@
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Providers
{
/// <summary>
/// Marker interface for custom metadata providers that run before the regular metadata refresh.
/// </summary>
public interface IPreRefreshProvider : ICustomMetadataProvider
{
}

View File

@@ -1,7 +1,8 @@
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Providers
{
/// <summary>
/// The lookup info for movies.
/// </summary>
public class MovieInfo : ItemLookupInfo
{
}

View File

@@ -1,7 +1,8 @@
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Providers
{
/// <summary>
/// The lookup info for persons.
/// </summary>
public class PersonLookupInfo : ItemLookupInfo
{
}

View File

@@ -1,7 +1,8 @@
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Providers
{
/// <summary>
/// The lookup info for series.
/// </summary>
public class SeriesInfo : ItemLookupInfo
{
}

View File

@@ -1,7 +1,8 @@
#pragma warning disable CS1591
namespace MediaBrowser.Controller.Providers
{
/// <summary>
/// The lookup info for trailers.
/// </summary>
public class TrailerInfo : ItemLookupInfo
{
}

View File

@@ -84,7 +84,7 @@ namespace MediaBrowser.LocalMetadata.Images
if (item.SupportsLocalMetadata)
{
// Episode has its own provider
if (item is Episode || item is Audio || item is Photo)
if (item is Episode || (item is Audio && item is not AudioBook) || item is Photo)
{
return false;
}

View File

@@ -6,6 +6,7 @@ using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
@@ -645,7 +646,9 @@ namespace MediaBrowser.MediaEncoding.Encoder
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false,
RedirectStandardInput = redirectStandardIn,
StandardOutputEncoding = Encoding.UTF8,
RedirectStandardOutput = true,
StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true
}
})

View File

@@ -7,6 +7,7 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using System.Threading;
@@ -528,6 +529,7 @@ namespace MediaBrowser.MediaEncoding.Encoder
UseShellExecute = false,
// Must consume both or ffmpeg may hang due to deadlocks.
StandardOutputEncoding = Encoding.UTF8,
RedirectStandardOutput = true,
FileName = _ffprobePath,

View File

@@ -254,16 +254,38 @@ namespace MediaBrowser.MediaEncoding.Probing
{
if (mediaStream.Type == MediaStreamType.Audio && !mediaStream.BitRate.HasValue)
{
mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Channels);
mediaStream.BitRate = GetEstimatedAudioBitrate(mediaStream.Codec, mediaStream.Profile, mediaStream.Channels);
}
}
var videoStreamsBitrate = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).Select(i => i.BitRate ?? 0).Sum();
// If ffprobe reported the container bitrate as being the same as the video stream bitrate, then it's wrong
if (videoStreamsBitrate == (info.Bitrate ?? 0))
// ffprobe frequently omits the per-stream video bitrate (common in MP4/MKV containers).
// Estimate the missing video bitrate as the container bitrate minus the combined stream bitrates.
var videoStreams = info.MediaStreams.Where(i => i.Type == MediaStreamType.Video).ToList();
if (info.Bitrate.HasValue
&& videoStreams.Count == 1
&& !videoStreams[0].BitRate.HasValue)
{
info.InferTotalBitrate(true);
var otherStreams = info.MediaStreams
.Where(i => i.Type != MediaStreamType.Video && !i.IsExternal)
.ToList();
// Only attribute the leftover bitrate to the video stream if every audio stream's bitrate is known.
var audioBitratesKnown = otherStreams
.Where(i => i.Type == MediaStreamType.Audio)
.All(i => i.BitRate.HasValue);
if (audioBitratesKnown)
{
var estimatedVideoBitrate = info.Bitrate.Value - otherStreams.Sum(i => i.BitRate ?? 0);
if (estimatedVideoBitrate > 0)
{
videoStreams[0].BitRate = estimatedVideoBitrate;
}
}
}
// If the container bitrate is still unknown, infer it from the sum of the streams.
info.InferTotalBitrate();
}
return info;
@@ -316,54 +338,34 @@ namespace MediaBrowser.MediaEncoding.Probing
return string.Join(',', splitFormat.Where(s => !string.IsNullOrEmpty(s)));
}
private static int? GetEstimatedAudioBitrate(string codec, int? channels)
internal static int? GetEstimatedAudioBitrate(string codec, string profile, int? channels)
{
if (!channels.HasValue)
if (!channels.HasValue || channels.Value < 1 || string.IsNullOrEmpty(codec))
{
return null;
}
var channelsValue = channels.Value;
// Rough typical bitrates used only as a fallback when ffprobe doesn't report a stream bitrate.
var channelCount = channels.Value;
var isMultichannel = channelCount > 2;
if (string.Equals(codec, "aac", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "mp3", StringComparison.OrdinalIgnoreCase))
return codec.ToLowerInvariant() switch
{
switch (channelsValue)
{
case <= 2:
return 192000;
case >= 5:
return 320000;
}
}
if (string.Equals(codec, "ac3", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "eac3", StringComparison.OrdinalIgnoreCase))
{
switch (channelsValue)
{
case <= 2:
return 192000;
case >= 5:
return 640000;
}
}
if (string.Equals(codec, "flac", StringComparison.OrdinalIgnoreCase)
|| string.Equals(codec, "alac", StringComparison.OrdinalIgnoreCase))
{
switch (channelsValue)
{
case <= 2:
return 960000;
case >= 5:
return 2880000;
}
}
return null;
"aac" or "mp3" or "mp2" => isMultichannel ? 320000 : 192000,
"ac3" or "eac3" => isMultichannel ? 640000 : 192000,
"dts" or "dca" => IsDtsLossless(profile) ? channelCount * 700000 : (isMultichannel ? 1509000 : 768000),
"opus" => isMultichannel ? 256000 : 128000,
"vorbis" => isMultichannel ? 320000 : 160000,
"wmav1" or "wmav2" or "wmapro" => isMultichannel ? 384000 : 192000,
"flac" or "alac" => channelCount * 480000,
"truehd" or "mlp" => channelCount * 700000,
_ => null
};
}
private static bool IsDtsLossless(string profile)
=> profile is not null && profile.Contains("HD MA", StringComparison.OrdinalIgnoreCase);
private void FetchFromItunesInfo(string xml, MediaInfo info)
{
// Make things simpler and strip out the dtd
@@ -972,10 +974,12 @@ namespace MediaBrowser.MediaEncoding.Probing
bitrate = value;
}
// The bitrate info of FLAC musics and some videos is included in formatInfo.
// The bitrate info of FLAC audio is included in formatInfo.
// Don't do this for video streams: formatInfo.BitRate is the overall container
// bitrate (video + audio + subtitles + overhead), not the video bitrate.
if (bitrate == 0
&& formatInfo is not null
&& (stream.Type == MediaStreamType.Video || (isAudio && stream.Type == MediaStreamType.Audio)))
&& isAudio && stream.Type == MediaStreamType.Audio)
{
// If the stream info doesn't have a bitrate get the value from the media format info
if (int.TryParse(formatInfo.BitRate, CultureInfo.InvariantCulture, out value))
@@ -1260,9 +1264,16 @@ namespace MediaBrowser.MediaEncoding.Probing
}
var duration = GetDictionaryValue(streamInfo.Tags, "DURATION-eng") ?? GetDictionaryValue(streamInfo.Tags, "DURATION");
if (TimeSpan.TryParse(duration, out var parsedDuration))
if (!string.IsNullOrEmpty(duration))
{
return parsedDuration.TotalSeconds;
// Matroska DURATION tags use nanosecond precision (e.g. "00:00:05.023000000"), but
// TimeSpan only supports up to 7 fractional digits (ticks). Trim the surplus digits so
// these durations parse instead of being silently dropped.
duration = DurationOverPrecisionRegex().Replace(duration, "$1");
if (TimeSpan.TryParse(duration, CultureInfo.InvariantCulture, out var parsedDuration))
{
return parsedDuration.TotalSeconds;
}
}
return null;
@@ -1630,7 +1641,7 @@ namespace MediaBrowser.MediaEncoding.Probing
// Credit to MCEBuddy: https://mcebuddy2x.codeplex.com/
// DateTime is reported along with timezone info (typically Z i.e. UTC hence assume None)
if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, null, DateTimeStyles.AdjustToUniversal, out var parsedDate))
if (tags.TryGetValue("WM/MediaOriginalBroadcastDateTime", out var premiereDateString) && DateTime.TryParse(year, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var parsedDate))
{
video.PremiereDate = parsedDate;
}
@@ -1764,5 +1775,8 @@ namespace MediaBrowser.MediaEncoding.Probing
[GeneratedRegex("(?<name>.*) \\((?<instrument>.*)\\)")]
private static partial Regex PerformerRegex();
[GeneratedRegex(@"(\.\d{7})\d+")]
private static partial Regex DurationOverPrecisionRegex();
}
}

View File

@@ -167,7 +167,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
if (fileInfo.Protocol == MediaProtocol.Http)
{
var result = await DetectCharset(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);
var result = await DetectCharset(fileInfo.Path, cancellationToken).ConfigureAwait(false);
var detected = result.Detected;
if (detected is not null)
@@ -445,98 +445,15 @@ namespace MediaBrowser.MediaEncoding.Subtitles
encodingParam = " -sub_charenc " + encodingParam;
}
int exitCode;
var args = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath);
using (var process = new Process
{
StartInfo = new ProcessStartInfo
{
CreateNoWindow = true,
UseShellExecute = false,
FileName = _mediaEncoder.EncoderPath,
Arguments = string.Format(CultureInfo.InvariantCulture, "-y {0} -i \"{1}\" -c:s srt \"{2}\"", encodingParam, inputPath, outputPath),
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
EnableRaisingEvents = true
})
{
_logger.LogInformation("{0} {1}", process.StartInfo.FileName, process.StartInfo.Arguments);
try
{
process.Start();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting ffmpeg");
throw;
}
try
{
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
{
process.Kill(true);
exitCode = -1;
}
}
var failed = false;
if (exitCode == -1)
{
failed = true;
if (File.Exists(outputPath))
{
try
{
_logger.LogInformation("Deleting converted subtitle due to failure: {Path}", outputPath);
_fileSystem.DeleteFile(outputPath);
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting converted subtitle {Path}", outputPath);
}
}
}
else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{
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)
{
_logger.LogError("ffmpeg subtitle conversion failed for {Path}", inputPath);
throw new FfmpegException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle conversion failed for {0}", inputPath));
}
await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
await ExtractSubtitlesForFile(
inputPath,
args,
[outputPath],
cancellationToken).ConfigureAwait(false);
WriteCacheMeta(outputPath, inputPath);
_logger.LogInformation("ffmpeg subtitle conversion succeeded for {Path}", inputPath);
}
private string GetExtractableSubtitleFormat(MediaStream subtitleStream)
@@ -727,7 +644,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
var outputPaths = new List<string>();
var args = string.Format(
CultureInfo.InvariantCulture,
"-i {0}",
"-y -i {0}",
inputPath);
foreach (var subtitleStream in subtitleStreams)
@@ -781,50 +698,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
private async Task ExtractSubtitlesForFile(
string inputPath,
string args,
List<string> outputPaths,
IReadOnlyList<string> outputPaths,
CancellationToken cancellationToken)
{
int exitCode;
using (var process = new Process
{
StartInfo = new ProcessStartInfo
{
CreateNoWindow = true,
UseShellExecute = false,
FileName = _mediaEncoder.EncoderPath,
Arguments = args,
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
EnableRaisingEvents = true
})
{
_logger.LogInformation("{File} {Arguments}", process.StartInfo.FileName, process.StartInfo.Arguments);
try
{
process.Start();
}
catch (Exception ex)
{
_logger.LogError(ex, "Error starting ffmpeg");
throw;
}
try
{
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
{
process.Kill(true);
exitCode = -1;
}
}
var (exitCode, ffmpegError) = await RunSubtitleExtractionProcess(args, cancellationToken).ConfigureAwait(false);
var failed = false;
@@ -884,6 +761,13 @@ namespace MediaBrowser.MediaEncoding.Subtitles
if (failed)
{
cancellationToken.ThrowIfCancellationRequested();
if (!string.IsNullOrWhiteSpace(ffmpegError))
{
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath}: {FfmpegOutput}", inputPath, ffmpegError);
}
throw new FfmpegException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0}", inputPath));
}
@@ -941,16 +825,38 @@ namespace MediaBrowser.MediaEncoding.Subtitles
ArgumentException.ThrowIfNullOrEmpty(outputPath);
Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException($"Provided path ({outputPath}) is not valid.", nameof(outputPath)));
var processArgs = string.Format(
CultureInfo.InvariantCulture,
"-i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
"-y -i {0} -copyts -map 0:{1} -an -vn -c:s {2} \"{3}\"",
inputPath,
subtitleStreamIndex,
outputCodec,
outputPath);
await ExtractSubtitlesForFile(
inputPath,
processArgs,
[outputPath],
cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Runs ffmpeg to extract or convert subtitles, capturing its exit code and stderr output.
/// </summary>
/// <remarks>
/// stdin is redirected and closed, and <c>-nostdin</c> is prepended to the arguments, so ffmpeg can never
/// block reading an inherited stdin handle (which happens when Jellyfin runs as a service, e.g. under NSSM,
/// and stalls subtitle extraction until the timeout). stderr is redirected and drained so a full pipe buffer
/// cannot deadlock ffmpeg and so its output can be surfaced on failure; stdout is left un-redirected as it is
/// unused for subtitle extraction.
/// </remarks>
/// <param name="arguments">The ffmpeg command line arguments.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The ffmpeg exit code (-1 on timeout) and its captured stderr output.</returns>
private async Task<(int ExitCode, string StandardError)> RunSubtitleExtractionProcess(string arguments, CancellationToken cancellationToken)
{
int exitCode;
var standardError = string.Empty;
using (var process = new Process
{
@@ -958,8 +864,10 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
CreateNoWindow = true,
UseShellExecute = false,
RedirectStandardInput = true,
RedirectStandardError = true,
FileName = _mediaEncoder.EncoderPath,
Arguments = processArgs,
Arguments = "-nostdin " + arguments,
WindowStyle = ProcessWindowStyle.Hidden,
ErrorDialog = false
},
@@ -975,14 +883,21 @@ namespace MediaBrowser.MediaEncoding.Subtitles
catch (Exception ex)
{
_logger.LogError(ex, "Error starting ffmpeg");
throw;
}
// Close stdin so ffmpeg observes EOF instead of blocking on an inherited handle.
process.StandardInput.Close();
// Begin draining stderr before waiting for exit; a full stderr pipe buffer would otherwise deadlock ffmpeg.
var standardErrorTask = process.StandardError.ReadToEndAsync(CancellationToken.None);
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
using var waitSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
waitSource.CancelAfter(TimeSpan.FromMinutes(timeoutMinutes));
try
{
var timeoutMinutes = _serverConfigurationManager.GetEncodingOptions().SubtitleExtractionTimeoutMinutes;
await process.WaitForExitAsync(TimeSpan.FromMinutes(timeoutMinutes)).ConfigureAwait(false);
await process.WaitForExitAsync(waitSource.Token).ConfigureAwait(false);
exitCode = process.ExitCode;
}
catch (OperationCanceledException)
@@ -990,59 +905,18 @@ namespace MediaBrowser.MediaEncoding.Subtitles
process.Kill(true);
exitCode = -1;
}
}
var failed = false;
if (exitCode == -1)
{
failed = true;
try
{
_logger.LogWarning("Deleting extracted subtitle due to failure: {Path}", outputPath);
_fileSystem.DeleteFile(outputPath);
standardError = await standardErrorTask.ConfigureAwait(false);
}
catch (FileNotFoundException)
catch (OperationCanceledException)
{
}
catch (IOException ex)
{
_logger.LogError(ex, "Error deleting extracted subtitle {Path}", outputPath);
}
}
else if (!File.Exists(outputPath) || _fileSystem.GetFileInfo(outputPath).Length == 0)
{
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);
// Reading ffmpeg output was cancelled; nothing more to capture.
}
}
if (failed)
{
_logger.LogError("ffmpeg subtitle extraction failed for {InputPath} to {OutputPath}", inputPath, outputPath);
throw new FfmpegException(
string.Format(CultureInfo.InvariantCulture, "ffmpeg subtitle extraction failed for {0} to {1}", inputPath, outputPath));
}
_logger.LogInformation("ffmpeg subtitle extraction completed for {InputPath} to {OutputPath}", inputPath, outputPath);
if (string.Equals(outputCodec, "ass", StringComparison.OrdinalIgnoreCase))
{
await SetAssFont(outputPath, cancellationToken).ConfigureAwait(false);
}
return (exitCode, standardError);
}
/// <summary>
@@ -1104,7 +978,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
}
var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
var result = await DetectCharset(path, cancellationToken).ConfigureAwait(false);
var charset = result.Detected?.EncodingName ?? string.Empty;
// UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
@@ -1120,8 +994,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles
return charset;
}
private async Task<DetectionResult> DetectCharset(string path, MediaProtocol protocol, CancellationToken cancellationToken)
private async Task<DetectionResult> DetectCharset(string path, CancellationToken cancellationToken)
{
var protocol = _mediaSourceManager.GetPathProtocol(path);
switch (protocol)
{
case MediaProtocol.Http:
@@ -1141,7 +1016,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
default:
throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol");
throw new NotSupportedException($"Unsupported protocol: {protocol}");
}
}

View File

@@ -424,6 +424,7 @@ public sealed class TranscodeManager : ITranscodeManager, IDisposable
// Must consume both stdout and stderr or deadlocks may occur
// RedirectStandardOutput = true,
StandardErrorEncoding = Encoding.UTF8,
RedirectStandardError = true,
RedirectStandardInput = true,
FileName = _mediaEncoder.EncoderPath,

View File

@@ -1,11 +1,23 @@
#pragma warning disable CS1591
namespace MediaBrowser.Model.Dlna
{
/// <summary>
/// The codec type of a codec profile.
/// </summary>
public enum CodecType
{
/// <summary>
/// The profile applies to a video codec.
/// </summary>
Video = 0,
/// <summary>
/// The profile applies to the audio codec of a video stream.
/// </summary>
VideoAudio = 1,
/// <summary>
/// The profile applies to an audio codec.
/// </summary>
Audio = 2
}
}

View File

@@ -1,10 +1,18 @@
#pragma warning disable CS1591
namespace MediaBrowser.Model.Dlna
{
/// <summary>
/// The encoding context.
/// </summary>
public enum EncodingContext
{
/// <summary>
/// The media is transcoded on the fly and delivered as a stream.
/// </summary>
Streaming = 0,
/// <summary>
/// The media is transcoded to a static file.
/// </summary>
Static = 1
}
}

View File

@@ -1,11 +1,23 @@
#pragma warning disable CS1591
namespace MediaBrowser.Model.Dlna
{
/// <summary>
/// The playback error code.
/// </summary>
public enum PlaybackErrorCode
{
/// <summary>
/// Playback of the item is not allowed.
/// </summary>
NotAllowed = 0,
/// <summary>
/// No stream compatible with the device profile was found.
/// </summary>
NoCompatibleStream = 1,
/// <summary>
/// The rate limit has been exceeded.
/// </summary>
RateLimitExceeded = 2
}
}

View File

@@ -1,11 +1,18 @@
#pragma warning disable CS1591
namespace MediaBrowser.Model.Dlna
{
/// <summary>
/// The resolution constraints.
/// </summary>
public class ResolutionOptions
{
/// <summary>
/// Gets or sets the maximum width.
/// </summary>
public int? MaxWidth { get; set; }
/// <summary>
/// Gets or sets the maximum height.
/// </summary>
public int? MaxHeight { get; set; }
}
}

View File

@@ -1,10 +1,18 @@
#pragma warning disable CS1591
namespace MediaBrowser.Model.Dlna
{
/// <summary>
/// The transcode seek info.
/// </summary>
public enum TranscodeSeekInfo
{
/// <summary>
/// The seek method is chosen automatically.
/// </summary>
Auto = 0,
/// <summary>
/// Seeking is performed by byte position.
/// </summary>
Bytes = 1
}
}

View File

@@ -1,17 +1,28 @@
#nullable disable
#pragma warning disable CS1591
using System.ComponentModel.DataAnnotations;
namespace MediaBrowser.Model.Session
{
/// <summary>
/// A command to display a message on a client.
/// </summary>
public class MessageCommand
{
/// <summary>
/// Gets or sets the message header.
/// </summary>
public string Header { get; set; }
/// <summary>
/// Gets or sets the message text.
/// </summary>
[Required(AllowEmptyStrings = false)]
public string Text { get; set; }
/// <summary>
/// Gets or sets the timeout in milliseconds after which the message should be dismissed.
/// </summary>
public long? TimeoutMs { get; set; }
}
}

View File

@@ -1,11 +1,23 @@
#pragma warning disable CS1591
namespace MediaBrowser.Model.Session
{
/// <summary>
/// The play method.
/// </summary>
public enum PlayMethod
{
/// <summary>
/// The media is transcoded before it is sent to the client.
/// </summary>
Transcode = 0,
/// <summary>
/// The media is remuxed into a compatible container but the streams are not re-encoded.
/// </summary>
DirectStream = 1,
/// <summary>
/// The media is sent to the client as-is.
/// </summary>
DirectPlay = 2
}
}

View File

@@ -1,11 +1,18 @@
#pragma warning disable CS1591
namespace MediaBrowser.Model.Session
{
/// <summary>
/// A request to change the playstate of a session.
/// </summary>
public class PlaystateRequest
{
/// <summary>
/// Gets or sets the playstate command.
/// </summary>
public PlaystateCommand Command { get; set; }
/// <summary>
/// Gets or sets the seek position in ticks.
/// </summary>
public long? SeekPositionTicks { get; set; }
/// <summary>

View File

@@ -1,13 +1,21 @@
#nullable disable
#pragma warning disable CS1591
using System;
namespace MediaBrowser.Model.Session;
/// <summary>
/// An item in a play queue.
/// </summary>
public record QueueItem
{
/// <summary>
/// Gets or sets the item id.
/// </summary>
public Guid Id { get; set; }
/// <summary>
/// Gets or sets the playlist item id.
/// </summary>
public string PlaylistItemId { get; set; }
}

View File

@@ -1,11 +1,23 @@
#pragma warning disable CS1591
namespace MediaBrowser.Model.Session
{
/// <summary>
/// The repeat mode of a play queue.
/// </summary>
public enum RepeatMode
{
/// <summary>
/// Nothing is repeated.
/// </summary>
RepeatNone = 0,
/// <summary>
/// The whole queue is repeated.
/// </summary>
RepeatAll = 1,
/// <summary>
/// The current item is repeated.
/// </summary>
RepeatOne = 2
}
}

View File

@@ -125,7 +125,7 @@ namespace MediaBrowser.Providers.Books.OpenPackagingFormat
ReadStringInto("//dc:date", date =>
{
if (DateTime.TryParse(date, out var dateValue))
if (DateTime.TryParse(date, CultureInfo.InvariantCulture, out var dateValue))
{
book.PremiereDate = dateValue.Date;
book.ProductionYear = dateValue.Date.Year;

View File

@@ -90,7 +90,7 @@ namespace MediaBrowser.Providers.Manager
{
ArgumentException.ThrowIfNullOrEmpty(mimeType);
var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && item is not Audio;
var saveLocally = item.SupportsLocalMetadata && item.IsSaveLocalMetadataEnabled() && !item.ExtraType.HasValue && (item is AudioBook || item is not Audio);
if (type != ImageType.Primary && item is Episode)
{

View File

@@ -680,11 +680,18 @@ namespace MediaBrowser.Providers.Manager
return providers;
}
protected virtual IEnumerable<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> allImageProviders, ImageRefreshOptions options)
protected virtual IEnumerable<IImageProvider> GetNonLocalImageProviders(BaseItem item, IEnumerable<IImageProvider> allImageProviders, MetadataRefreshOptions options)
{
// Get providers to refresh
var providers = allImageProviders.Where(i => i is not ILocalImageProvider);
// When identifying, run the provider the user picked first so the correct image is used.
if (!string.IsNullOrEmpty(options.SearchResult?.SearchProviderName))
{
providers = providers
.OrderBy(i => string.Equals(i.Name, options.SearchResult.SearchProviderName, StringComparison.OrdinalIgnoreCase) ? 0 : 1);
}
var dateLastImageRefresh = item.DateLastRefreshed;
// Run all if either of these flags are true

View File

@@ -256,11 +256,20 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
series.Overview = seriesResult.Overview;
var studios = Enumerable.Empty<string>();
if (seriesResult.Networks is not null)
{
series.Studios = seriesResult.Networks.Select(i => i.Name).ToArray();
studios = studios.Concat(seriesResult.Networks.Select(i => i.Name).OfType<string>());
}
if (seriesResult.ProductionCompanies is not null)
{
studios = studios.Concat(seriesResult.ProductionCompanies.Select(i => i.Name).OfType<string>());
}
series.SetStudios(studios);
if (seriesResult.Genres is not null)
{
series.Genres = seriesResult.Genres.Select(i => i.Name).ToArray();

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