Compare commits

...

257 Commits

Author SHA1 Message Date
Cody Robibero
fc43f151a2 Merge pull request #17227 from altqx/master
Match VobSub MKS subtitle profiles by container
2026-07-21 20:47:02 -04:00
Cody Robibero
526f4051e9 Merge pull request #16980 from TheMelmacian/feature/library_specific_language_filter_values
Improve language filters to only fetch language codes that match the requested items/libraries (follow up to #9787)
2026-07-21 20:43:31 -04:00
Cody Robibero
635fd0433d Merge pull request #17370 from zerafachris/fix/item-update-null-optional-fields
fix: don't throw ArgumentNullException on partial UpdateItem payloads (#17366)
2026-07-21 18:17:22 -04:00
Cody Robibero
370170bab0 Merge pull request #17369 from Shadowghost/harden-startup-wizard
Prevent unauthenticated re-run of the startup wizard on misconfiguration
2026-07-21 18:17:08 -04:00
TheMelmacian
b317af0d30 fix: remove obsolete code 2026-07-21 22:20:07 +02:00
Cody Robibero
65836cc844 Merge pull request #17160 from 854562/truncate-language-strings
Truncate ISO-639-2 language display names at first delimiter
2026-07-21 11:22:32 -04:00
aivarsse
b4090bdcb2 Translated using Weblate (Latvian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lv/
2026-07-21 14:35:53 +00:00
Tim Eisele
ed61acc19a Fix subtitle encoding for local files (#17281)
* Fix subtitle encoding

* Add short-circuit

* Use IsTextFormat

* Update MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs

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

---------

Co-authored-by: Bond-009 <bond.009@outlook.com>
2026-07-21 14:52:59 +02:00
Bond-009
9b9b609c83 Merge pull request #17368 from Shadowghost/security-path-traversal-fixes
Backport and extend path traversal fixes
2026-07-21 14:52:40 +02:00
Shadowghost
4cc69f4be0 Apply review suggestions 2026-07-21 14:30:55 +02:00
Bond-009
527ba2e11c Merge pull request #17367 from zerafachris/fix/backup-skip-corrupt-keyframe-data
fix: skip corrupt KeyframeData rows during full system backup
2026-07-21 13:53:47 +02:00
Bond-009
25d4e207f7 Merge pull request #17317 from jellyfin/renovate/sharpcompress-0.x
Update dependency SharpCompress to 0.50.0
2026-07-21 13:38:02 +02:00
Bond-009
7a269987d2 Merge pull request #17391 from jellyfin/renovate/ci-deps
Update actions/checkout action to v7.0.1
2026-07-21 13:36:21 +02:00
Bond-009
081944d358 Merge pull request #17376 from mbastian77/docs/model-enums-xml-docs
Add XML docs to small model enums and remove CS1591 suppressions
2026-07-21 13:13:06 +02:00
zerafachris
299810a4a9 fix: use build output directory for backup test temp root to avoid low free-space failures on Windows CI runners
BackupServiceTests rooted its temp directory under Path.GetTempPath(), which
on GitHub-hosted windows-latest runners resolves to the constrained system C:
drive. BackupService.CreateBackupAsync requires 5GiB free at the backup path
before starting, and the C: drive's free temp space can dip below that,
failing CreateBackupAsync_WithCorruptKeyframeDataRow_SkipsRowAndCompletesBackup
even though the fix itself is correct. Rooting the test directory under
AppContext.BaseDirectory keeps it on the same (much larger) drive as the repo
checkout on all platforms, without touching the real BackupService free-space
check.
2026-07-21 08:54:23 +02:00
zerafachris
53e58d8b1b Make ItemUpdateController.UpdateItem internal instead of reflection
Addresses review feedback from @Bond-009 on PR #17370: the test helper
InvokeUpdateItem was invoking the private UpdateItem(BaseItemDto, BaseItem)
method via reflection. Jellyfin.Api.csproj already grants
InternalsVisibleTo("Jellyfin.Api.Tests"), so the method is changed to
internal and the test now calls it directly, removing the
GetMethod/Invoke boilerplate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-21 08:51:30 +02:00
Shadowghost
b99703301f Merge remote-tracking branch 'upstream/master' into security-path-traversal-fixes
# Conflicts:
#	Jellyfin.Api/Controllers/HlsSegmentController.cs
#	Jellyfin.Api/Controllers/PluginsController.cs
2026-07-21 07:14:47 +02:00
Cody Robibero
bdf263d867 Merge pull request #17377 from mbastian77/fix/person-visibility-allowed-tags
Exempt people from the allowed tags visibility check
2026-07-20 21:26:06 -04:00
Shed Shedson
9d5aedba5f Translated using Weblate (Icelandic)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/is/
2026-07-21 00:52:58 +00:00
Cody Robibero
0f88fe3c7f Merge pull request #17311 from dkanada/book-progress
extract page count from archives and PDFs
2026-07-20 20:50:25 -04:00
Cody Robibero
557b14e33e Merge branch 'master' into fix/backup-skip-corrupt-keyframe-data 2026-07-20 20:49:37 -04:00
Cody Robibero
c222d370ce Merge pull request #16933 from WizardOfYendor1/fix/livetv-guide-image-optimization
Feat (fix) - Skip reprocessing program information when importing XMLTV EPG data
2026-07-20 20:17:20 -04:00
Cody Robibero
0d629591ed Remove comments about JSON error handling
Removed comments explaining error handling for malformed JSON during backup.
2026-07-20 20:15:05 -04:00
Cody Robibero
23fa02eb59 Merge pull request #17282 from TowyTowy/fix/13137-clear-profile-image
Fix profile image being impossible to clear when its in-memory key is temporary
2026-07-20 19:58:43 -04:00
Cody Robibero
bea016c962 Merge pull request #17320 from TaterTechStudios/fix/item-correct-selector
Fix: Fetch the correct row matching the most up to date file
2026-07-20 19:57:28 -04:00
Cody Robibero
fae1e4c556 Merge pull request #17324 from damienmeur/refactor/generic-getorderby
Make RequestHelpers.GetOrderBy generic and reuse it in ActivityLogController
2026-07-20 19:52:10 -04:00
Cody Robibero
f2b9c68969 Merge pull request #17342 from LTe/fix-subtitle-conversion-race
Fix race condition in concurrent subtitle conversion
2026-07-20 19:51:44 -04:00
renovate[bot]
e04ac6fd35 Update dependency SharpCompress to 0.50.0 2026-07-20 23:46:31 +00:00
renovate[bot]
25011224cf Update actions/checkout action to v7.0.1 2026-07-20 23:46:21 +00:00
Cody Robibero
191be0931e Merge pull request #17254 from sjakub/attribute_aliases
Add additional attribute aliases and improve attribute detection
2026-07-20 19:45:06 -04:00
854562
2cd2f36fe4 Extract truncation logic to helper and add tests 2026-07-20 22:00:10 +02:00
Bond-009
4f19ed5730 Merge pull request #17204 from theguymadmax/sort-seires-trailers
Sort trailers for TV Shows
2026-07-20 12:31:31 +02:00
Bond-009
5949be852a Fix SchedulesDirect image limit recognition (#17347) 2026-07-20 12:28:10 +02:00
theguymadmax
ffe075650c Add TVDB provider ID support for movies (#17255)
Add TVDB provider ID support for movies
2026-07-20 12:23:20 +02:00
Bond-009
abb35571ef Merge pull request #17252 from theguymadmax/fix-alubms-and-artists
Fix artists being displayed with albums
2026-07-20 12:20:49 +02:00
Bond-009
474399683d Merge pull request #17386 from jellyfin/renovate/actions-setup-python-7.x
Update actions/setup-python action to v7
2026-07-20 12:16:40 +02:00
gnattu
a238d59a07 Remove libpostproc check for ffmpeg version validation (#17384)
Remove libpostproc check for ffmpeg version validation
2026-07-20 12:16:37 +02:00
Bond-009
c779488ade Merge pull request #17382 from kaunkrishna/master
Fix linked whitespace after image badges in `README.md`
2026-07-20 12:15:54 +02:00
krvi
cc44f333b6 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-07-20 08:34:15 +00:00
Milo Ivir
2b6302c06e Translated using Weblate (Croatian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/hr/
2026-07-20 08:34:14 +00:00
Bond-009
c3901fde56 Merge pull request #17291 from dkanada/creator-normalization
normalize common formats for creator names in OPF data
2026-07-20 08:39:19 +02:00
Bond-009
aead9c0e22 Merge pull request #17302 from ElianCodes/fix/lastlogindate-stale-entity
Keep authenticated user entity in sync with persisted login timestamps
2026-07-20 08:36:53 +02:00
Bond-009
2fd747432a Merge pull request #17304 from nyanmisaka/normalize-invalid-pts-for-trickplay
Normalize invalid PTS from containers for Trickplay generation
2026-07-20 08:36:25 +02:00
Bond-009
236db6d2f6 Merge pull request #17365 from Shadowghost/fix-resume-perf
Fix Resume query performance
2026-07-20 08:17:38 +02:00
Bond-009
181c057cb8 Merge pull request #17375 from mbastian77/docs/entities-xml-docs
Add XML docs to small entity interfaces and remove CS1591 suppressions
2026-07-20 08:17:04 +02:00
renovate[bot]
7daef156e5 Update actions/setup-python action to v7 2026-07-20 06:12:52 +00:00
KAUN
f068ca3bd6 Update README.md 2026-07-19 13:24:22 +05:30
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
TheMelmacian
7d83779b6f fix code style 2026-07-18 17:56:38 +02:00
zerafachris
663a873e07 fix: log corrupt KeyframeData row read failures as errors, not warnings
Per review feedback from cvium: failing to read/backup an entity due to
corrupt underlying data is a significant event that should be surfaced
as an error, not silently downgraded to a warning.
2026-07-18 16:47:23 +02: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
mbastian77
5a9fb80239 Exempt people from the allowed tags visibility check 2026-07-18 14:07:52 +02:00
mbastian77
8ac2d1f7bc Add XML docs to small model enums and remove CS1591 suppressions 2026-07-18 10:08:55 +02:00
nyanmisaka
9fa9d26341 Normalize invalid PTS from containers for Trickplay generation
This change does not affect the keyframe only mode.

Signed-off-by: nyanmisaka <nst799610810@gmail.com>
2026-07-18 15:44:34 +08: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
zerafachris
5cd3d7ebb7 fix: don't throw ArgumentNullException on partial UpdateItem payloads (#17366)
BaseItemDto.Genres, .Tags, and .ProviderIds are plain auto-properties with
no default initializer, so they deserialize to null when a client omits
them from a partial POST /Items/{itemId} body. The OpenAPI spec documents
every BaseItemDto field as optional, but ItemUpdateController.UpdateItem
fed these three properties straight into Distinct()/Select()/ToList()
without a null check, so a request that (for example) only sets Tags
throws ArgumentNullException("source") once it reaches the unguarded
Genres line, before Tags is even processed.

Guard all three assignments with the same "if (request.X is not null)"
pattern already used for the neighboring Studios/Taglines/ProductionLocations
fields in this method, so omitted fields are left unchanged instead of
crashing the request.

Adds ItemUpdateControllerTests covering the reported repro (only Tags
supplied) and a companion case asserting existing Genres/ProviderIds are
preserved when omitted from the payload.

Signed-off-by: zerafachris <christopher.zerafa@blocklabs.io>
2026-07-17 17:20:30 +02:00
Shadowghost
62a5ded920 Prevent unauthenticated re-run of the startup wizard on misconfiguration 2026-07-17 17:14:27 +02:00
Shadowghost
21801e8ba1 Harden remaining path-construction sinks against traversal 2026-07-17 17:08:59 +02:00
Shadowghost
1a45fc82b5 Sanitize media attachment and lyric paths against traversal 2026-07-17 17:07:27 +02:00
Shadowghost
4fb779920a Sanitize ClientLog upload filename to prevent path traversal 2026-07-17 17:02:02 +02:00
zerafachris
d7727224c2 Skip corrupt KeyframeData rows during full system backup
A single row with malformed KeyframeTicks JSON (e.g. a truncated array
from an interrupted write) currently aborts the entire backup, because
the try/catch in BackupService.CreateBackupAsync only wraps
serialization of an already-materialized entity, not the enumeration
itself. EF Core throws JsonReaderException from MoveNextAsync() while
materializing the corrupt row, which propagates past that catch block.

Switch to manual enumerator iteration so MoveNextAsync() failures can
be caught per-row, logged as a warning identifying the affected table,
and skipped, allowing the remaining rows and the rest of the backup to
complete.

Fixes #17216

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-17 16:44:25 +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
Shadowghost
cdf7ce0fc4 Fix user data batch query perf 2026-07-16 14:21:03 +02:00
Shadowghost
d894a98b79 Fix tie-breaker performance 2026-07-16 14:20:42 +02:00
renovate[bot]
2b57a5725c Update actions/setup-dotnet action to v6 2026-07-16 07:02:47 +00:00
Jordan Rushing
3f96790904 Move GetUserDataBatch to use ResolveUserDataRow when item.UserData isn't preloaded 2026-07-15 16:21:42 -05:00
Shadowghost
2bac9a8f0c Fix SchedulesDirect image limit recognition 2026-07-15 17:43:27 +02:00
mbastian77
997093ae3a Add XML docs to small entity interfaces and remove CS1591 suppressions 2026-07-15 16:07:56 +02: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
Piotr Niełacny
6e3c187493 Fix race condition in concurrent subtitle conversion
SubtitleEncoder.ConvertSubtitles parsed subtitles with libse's static
Subtitle.Parse, which iterates a statically cached list of shared
SubtitleFormat instances. Format parsers keep mutable per-parse state on
the instance, so concurrent subtitle requests corrupted each other's
output (cues mixed across streams and languages, truncated files) or
failed with NullReferenceException when format detection broke down and
Subtitle.Parse returned null.

Parse through the injected ISubtitleParser instead. SubtitleEditParser
instantiates a fresh format parser per call, so requests no longer share
state. Its Parse method now returns the libse Subtitle directly (the
SubtitleTrackInfo flattening was unused since the SubtitleEdit writer
rework) so the writers keep full fidelity such as ASS styling.
2026-07-15 14:55:55 +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
Damien Meur
56c970c2db Make RequestHelpers.GetOrderBy generic and reuse it in ActivityLogController 2026-07-14 02:57:15 +02:00
Jordan Rushing
fcce108948 Fix: Fetch the correct row matching the most up to date file 2026-07-13 15:40:40 -05: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
TheMelmacian
2a44c35224 Apply review suggestions 2026-07-13 20:01:01 +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
dkanada
9e996d612c extract page count from archives and PDFs 2026-07-12 19:46:56 +09:00
theguymadmax
e2d9d592bc Fix incorrect protocol used for subtitle charset detection 2026-07-11 17:33:06 -04:00
Elian Van Cutsem
47f567b6f1 Keep authenticated user entity in sync with persisted login timestamps
ExecuteUpdateAsync bypasses the EF change tracker, so the user entity
returned by AuthenticateUser still carried the old LastLoginDate and
LastActivityDate. SessionManager.LogSessionActivity then saved that
stale entity in full, reverting LastLoginDate (usually to null)
milliseconds after every login. Setting the properties on the entity
keeps the follow-up save consistent and lets the 60-second activity
guard skip the redundant write during login.

Fixes #17301
2026-07-11 14:04:07 +02:00
dkanada
305160d2e4 enable audiobook image saving to local folder 2026-07-11 11:00:46 +09:00
dkanada
2250015e78 normalize common formats for creator names in OPF data 2026-07-10 22:53:14 +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
TowyTowy
2326ecdedc Fix profile image being impossible to clear when its in-memory key is temporary
ClearProfileImageAsync removed the ProfileImage instance attached to the
passed-in User, but that instance can carry a stale, never-persisted
(temporary) key because UpdateUserAsync creates the persisted image on a
separately loaded entity and never copies the generated key back.
Removing that detached entity on a fresh DbContext made EF Core throw
InvalidOperationException ('ImageInfo.Id has a temporary value'), leaving
the profile image impossible to delete or replace.

Load the tracked, persisted user and remove its actual ProfileImage,
matching the removal pattern already used in UpdateUserAsync. Adds
regression tests covering the temporary-key case and the no-image no-op
(the first fails before this change and passes after).

Fixes #13137

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 13:39:09 +02: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
Jakub Schmidtke
08f6627a24 Replaced string.Empty with ReadOnlySpan<char>.Empty 2026-07-08 01:40:26 +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
theguymadmax
fff710585f Restore music collection image override 2026-07-07 10:24:18 -04:00
Jakub Schmidtke
1294990f4f Added more aliases for attributes
Adds tvdb alias for tvdbid and imdb alias for imdbid.

It also fixes an issue where tmdb alias was being ignored
if it was followed by something like "tmdbidfoo".
The same issue prevented imdb pattern matching from
working, if it was followed by something like "imdbidfoo".

It also allows for detecting the first matching occurence,
whether it was an alias or not.

Finally, it ignores attributes with values consisting of only whitespaces.
2026-07-07 15:39:09 +02: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
theguymadmax
ab0d0d1890 Fix artists being displayed with albums 2026-07-06 22:06:34 -04: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
altqx
8622c3bfb7 Match VobSub MKS subtitle profiles by container 2026-07-03 09:30:22 +07: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
theguymadmax
22792b62cb Sort trailers for TV Shows 2026-06-29 12:04:55 -04: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
Ricky Kimani
feef2403c4 Translated using Weblate (Swahili)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/sw/
2026-06-28 10:04:42 +00:00
Bond-009
1b6342e217 Merge pull request #17131 from jellyfin/renovate/actions-checkout-7.x
Update actions/checkout action to v7
2026-06-28 11:20:09 +02:00
Bond-009
62e6cf0196 Merge pull request #17141 from jellyfin/renovate/swashbuckle-aspnetcore-monorepo
Update swashbuckle-aspnetcore monorepo to 10.2.3
2026-06-28 11:19:32 +02:00
Bond-009
8c6ee890cb Merge pull request #17167 from jellyfin/renovate/microsoft
Update dependency Microsoft.NET.Test.Sdk to 18.7.0
2026-06-28 11:19:11 +02:00
Bond-009
eee26e6fee Merge pull request #17176 from jellyfin/renovate/ci-deps
Update CI dependencies
2026-06-28 11:18:55 +02:00
Cody Robibero
fb07067f8f Merge pull request #17140 from theguymadmax/clean-orphaned-people
Remove orphaned people
2026-06-27 10:24:50 -04:00
Cody Robibero
a83920c5a7 Merge pull request #17162 from Shadowghost/batch-duplicate-cleanup-deletes
Batch duplicate-cleanup deletes in merge migrations
2026-06-27 10:22:11 -04:00
Cody Robibero
75d71cb73c Merge branch 'master' into clean-orphaned-people 2026-06-27 10:02:33 -04:00
Cody Robibero
c158418e0b Merge pull request #17013 from dfederm/dfederm/fix-jellyfin-16899
Reject unsafe plugin package names in installer
2026-06-27 10:00:00 -04:00
Cody Robibero
cbef19c313 Merge pull request #16914 from danieltutuianu/fix/livetv-channel-icon-refresh
Live TV: re-fetch channel icons on guide refresh
2026-06-27 09:52:51 -04:00
Cody Robibero
fc13a7ca7d Merge pull request #17174 from obrenoalvim/fix/use-tohexstringlower
Use Convert.ToHexStringLower for Schedules Direct password hash
2026-06-27 09:48:59 -04:00
Cody Robibero
ff36b1b417 Merge pull request #17154 from joshuaboniface/enhance-startup-ux
Revamp startup UI for visual style and usability
2026-06-27 09:48:42 -04:00
Cody Robibero
9ec19b8244 Merge pull request #17134 from theguymadmax/replace-embedded-lyrics
Fix embedded lyrics not updating on replace all refresh
2026-06-27 09:48:34 -04:00
Cody Robibero
ed5e868a6b Merge pull request #17187 from Shadowghost/fix-localization-lookup
Fix localization lookup
2026-06-27 09:46:45 -04:00
Cody Robibero
58de9b7a99 Merge pull request #17178 from jellyfin/fix-livetv-sessions
Fix Live TV tuner not releasing
2026-06-27 09:20:00 -04:00
Cody Robibero
aa037c748a Merge pull request #17188 from Shadowghost/fix-local-plugins
Fix local Comic book plugin registration
2026-06-27 09:18:00 -04:00
Cody Robibero
1efdad3443 Merge pull request #17182 from Shadowghost/vacuum-noscan
Don't run heavy DB tasks while scan is running
2026-06-27 09:14:49 -04:00
Manuel Cid
f2ed842b4b Translated using Weblate (Galician)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/gl/
2026-06-27 06:45:32 +00: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
c2cb18a9d1 Fix local plugin registration 2026-06-26 11:42:28 +02:00
Shadowghost
f398b6d08b Fix localization lookup 2026-06-26 08:20:55 +02:00
Shadowghost
fa07a3abe8 Skip backups whens can is running 2026-06-26 07:34:19 +02:00
cloudharps
b9db4566a7 Translated using Weblate (Korean)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ko/
2026-06-26 04:51:39 +00:00
Joshua M. Boniface
d71b17fcc7 Merge pull request #17153 from joshuaboniface/fix-FixIncorrectOwnerIdRelationships
Fix too many SQL variables in DeleteItem for large batch deletes
2026-06-26 00:51:36 -04:00
renovate[bot]
dff84c8490 Update CI dependencies 2026-06-26 03:42:17 +00:00
Shadowghost
1947296edd Don't run heavy DB tasks while scan is running 2026-06-25 19:32:36 +02:00
Joshua M. Boniface
31070e8208 Add a cancelable redirect handoff and drop the transitional migration status
When the server finishes starting, show "Jellyfin started successfully" with a
5-second "Redirecting in N…" countdown and a Cancel button instead of reloading
immediately. Cancel stops the countdown and the background refresh so the
startup output can be reviewed, and offers a "Continue to Jellyfin" button to
reload manually. The buttons use the web client's emby-button styling.

Also drop the transitional "Applying migrations" activity: it only showed
briefly while the pending migration set was read, or for the whole step when
nothing was pending, so startup now goes from "Preparing migrations" straight
into "Running migration X of Y".
2026-06-25 00:42:31 -04:00
Joshua M. Boniface
2c98ad99db Improve UX to fully match Jellyfin dashboards 2026-06-25 00:08:20 -04:00
theguymadmax
e26f4a1005 Fix Live TV tuner not releasing 2026-06-24 22:30:12 -04:00
Breno Alvim
e41f415594 Use Convert.ToHexStringLower for Schedules Direct password hash 2026-06-23 23:13:33 -03:00
854562
c632417dda Fix SonarCloud warnings 2026-06-23 18:32:47 +02:00
Shadowghost
d090c59939 Rework bitrate reporting 2026-06-23 17:47:17 +02:00
renovate[bot]
da515e94b1 Update Microsoft 2026-06-23 08:58:47 +00:00
nextlooper42
987744529a Translated using Weblate (Slovak)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/sk/
2026-06-23 08:15:55 +00:00
engineer948
917244ab1d Translated using Weblate (Azerbaijani)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/az/
2026-06-23 07:55:24 +00:00
Shadowghost
af82aceadb Batch duplicate-cleanup deletes in merge migrations 2026-06-22 23:16:47 +02:00
engineer948
7f2cd5cf57 Added translation using Weblate (Azerbaijani) 2026-06-22 21:09:26 +00:00
renovate[bot]
2feb588db3 Update swashbuckle-aspnetcore monorepo to 10.2.3 2026-06-22 19:13:35 +00:00
854562
94d5326411 Truncate ISO-639-2 language display names at first delimiter
Prevents raw ISO-639-2 values (e.g. "Greek, Modern (1453-)" from cluttering the audio and subtitle display names by truncating them at the first comma or semicolon ("Greek"). Applies to MediaStreamRepository and ProbeResultNormalizer.
2026-06-22 20:25:13 +02:00
Joshua M. Boniface
58e9e3423a Remove server version from page title
This leaks additional information publicly, and is not really
necessary/useful.
2026-06-22 02:32:59 -04:00
Jensen
da2b994fff Translated using Weblate (English (United States))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/en_US/
2026-06-22 06:20:06 +00:00
Jensen
7f7e4dfa40 Added translation using Weblate (English (United States)) 2026-06-22 04:52:24 +00:00
Joshua M. Boniface
c257fd5004 Make the startup log a bounded, soft-refreshing scrolling viewport
Order the startup log oldest-to-newest inside a height-bounded panel that
scrolls internally and never extends past the bottom of the window. Refresh it
with a background fetch that swaps the log list in place instead of reloading
the whole page, preserving the user's scroll position and only following to the
bottom when they are already there. A full page reload now happens only on the
final transition to the running server or to the error state.
2026-06-22 00:00:38 -04:00
Joshua M. Boniface
0046adda29 Restyle the startup UI and add a generic startup activity line
Restyle the startup/migration holding page to match the Jellyfin dark theme,
with the inline wordmark logo, a gradient spinner and a recolored startup log
tree, and move the Morestachio template rendering into a reusable
StartupUiRenderer.

Add a curated, non-identifying "current activity" line to the always-visible
header (for example "Initializing server" or "Running migration X of Y"),
reported from the startup flow and the migration service so it never leaks
server details to unauthenticated clients. Move the log download into a
"Download logs" link in the log panel header, and show only the header, with
no log hints, to non-local clients.
2026-06-22 00:00:38 -04:00
Joshua M. Boniface
b60c535c84 Add progress logging and batch deletion for logs
After resolving duplicates the migration deleted all items in one silent
pass (per-id GetItemById plus a single DeleteItemsUnsafeFast), which looks
hung for minutes on large libraries. Delete in batches of 500 and log
progress per batch, which also avoids one oversized delete transaction.
2026-06-21 23:31:25 -04:00
Joshua M. Boniface
069eb40ebf Fix too many SQL variables in DeleteItem for large batch deletes
The FixIncorrectOwnerIdRelationships migration deletes all duplicate
items in a single DeleteItemsUnsafeFast -> DeleteItem(ids) call. Inside
DeleteItem, the owned-extras lookup used a raw HashSet.Contains, which EF
inlines as one SQL variable per id and overflows SQLite's variable limit
on large libraries. Use WhereOneOrMany so the id set is bound as a single
json_each parameter, like the rest of the method, making bulk deletes
work for unlimited library sizes.
2026-06-21 23:03:45 -04:00
theguymadmax
c49f279a27 Fix Identify returning wrong images 2026-06-21 18:22:00 -04:00
theguymadmax
310a47c1d4 Reorder ValidatePeople 2026-06-19 23:10:32 -04:00
Shadowghost
0fb042b740 Surface the played version for resume 2026-06-19 21:51:57 +02:00
theguymadmax
24886d4849 Remove orphaned people 2026-06-19 13:28:22 -04:00
theguymadmax
c6c72f30ec Fix embedded lyrics not updating on replace-all refresh 2026-06-18 18:14:10 -04:00
renovate[bot]
528593efbf Update actions/checkout action to v7 2026-06-18 15:50:57 +00:00
Daniel Țuțuianu
1ea525a408 Merge branch 'master' into fix/livetv-channel-icon-refresh
Resolve GuideManager conflict by keeping LiveTvChannelImageHelper so
channel icons re-fetch on every guide refresh, including when the URL
is unchanged.
2026-06-17 06:16:42 +03: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
David Federman
0ed27bad65 Address PR comment 2026-06-06 21:55:30 -07:00
David Federman
26a149a970 Address PR comment 2026-06-03 08:04:39 -07:00
David Federman
5104497331 Reject unsafe plugin package names in installer 2026-06-02 23:12:50 -07:00
TheMelmacian
7939f3b009 only fetch language codes for the requested library when generating filter values 2026-05-30 16:33:59 +02:00
WizardOfYendor1
e1d63c0ea0 Fixed issue etag info was not being set until the 3rd time though processing due to the "isNew" condition. Etag wouldn't be saved/persistent until the 3rd time through and onward.
Without this change it's self healing after the 3rd cycle.

It also appears there may be an issue with this etag "skip if hash hasn't changed" for schedules direct functionality.... like it never will work. But out of scope here.

Also fixed Sonar gripes about code formatting
2026-05-25 20:04:45 -04:00
WizardOfYendor1
e1a16b4ec6 Add XMLTV guide content ETags 2026-05-25 20:04:21 -04:00
Daniel Țuțuianu
372c1681d8 Refresh Live TV channel icons on every guide update.
Guide refresh skipped channel logos once a primary image existed, so stale EPG/tuner icons never got replaced.
2026-05-23 23:29:25 +03:00
chasus
9569b4550d Fix Swagger UI auth docs (#12990) 2026-05-24 00:04:10 +10:00
222 changed files with 7319 additions and 1303 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

@@ -24,21 +24,21 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Setup .NET
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.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

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

View File

@@ -15,9 +15,9 @@ jobs:
format-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ env.SDK_VERSION }}

View File

@@ -20,9 +20,9 @@ jobs:
runs-on: "${{ matrix.os }}"
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
- uses: actions/setup-dotnet@a98b56852c35b8e3190ac28c8c2271da59106c68 # v6.0.0
with:
dotnet-version: ${{ env.SDK_VERSION }}

View File

@@ -24,7 +24,7 @@ jobs:
reactions: '+1'
- name: Checkout the latest code
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
token: ${{ secrets.JF_BOT_TOKEN }}
fetch-depth: 0
@@ -40,12 +40,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: pull in script
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
cache: 'pip'

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

@@ -10,12 +10,12 @@ jobs:
issues: write
steps:
- name: pull in script
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
repository: jellyfin/jellyfin-triage-script
- name: install python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.14'
cache: 'pip'

View File

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

View File

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

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

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

View File

@@ -26,33 +26,34 @@
<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.6.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" />
<PackageVersion Include="NEbml" Version="1.1.0.5" />
<PackageVersion Include="Newtonsoft.Json" Version="13.0.4" />
<PackageVersion Include="PDFtoImage" Version="5.2.1" />
<PackageVersion Include="PlaylistsNET" Version="1.4.1" />
<PackageVersion Include="prometheus-net.AspNetCore" Version="8.2.1" />
<PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.1" />
@@ -67,7 +68,7 @@
<PackageVersion Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.1" />
<PackageVersion Include="SerilogAnalyzer" Version="0.15.0" />
<PackageVersion Include="SharpCompress" Version="0.49.1" />
<PackageVersion Include="SharpCompress" Version="0.50.0" />
<PackageVersion Include="SharpFuzz" Version="2.3.0" />
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
@@ -75,9 +76,9 @@
<PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" />
<PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.556" />
<PackageVersion Include="Svg.Skia" Version="3.7.0" />
<PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="10.2.1" />
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.1" />
<PackageVersion Include="System.Text.Json" Version="10.0.9" />
<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.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

@@ -39,6 +39,8 @@ using Emby.Server.Implementations.SyncPlay;
using Emby.Server.Implementations.TV;
using Emby.Server.Implementations.Updates;
using Jellyfin.Api.Helpers;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Drawing;
using Jellyfin.MediaEncoding.Hls.Playlist;
using Jellyfin.Networking.Manager;
@@ -93,6 +95,9 @@ using MediaBrowser.Model.Net;
using MediaBrowser.Model.Serialization;
using MediaBrowser.Model.System;
using MediaBrowser.Model.Tasks;
using MediaBrowser.Providers.Books;
using MediaBrowser.Providers.Books.ComicBookInfo;
using MediaBrowser.Providers.Books.ComicInfo;
using MediaBrowser.Providers.Lyric;
using MediaBrowser.Providers.Manager;
using MediaBrowser.Providers.Plugins.ListenBrainz;
@@ -414,6 +419,8 @@ namespace Emby.Server.Implementations
{
Logger.LogInformation("Running startup tasks");
EnsureStartupWizardIntegrity();
Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false));
ConfigurationManager.ConfigurationUpdated += OnConfigurationUpdated;
@@ -433,6 +440,24 @@ namespace Emby.Server.Implementations
return Task.CompletedTask;
}
private void EnsureStartupWizardIntegrity()
{
if (ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted)
{
return;
}
var hasConfiguredAdministrator = Resolve<IUserManager>().GetUsers()
.Any(user => user.HasPermission(PermissionKind.IsAdministrator) && !string.IsNullOrEmpty(user.Password));
if (hasConfiguredAdministrator)
{
Logger.LogWarning("The startup wizard is marked incomplete but a configured administrator already exists. Marking setup as completed to prevent the unauthenticated setup endpoints from being reachable.");
ConfigurationManager.Configuration.IsStartupWizardCompleted = true;
ConfigurationManager.SaveConfiguration();
}
}
/// <inheritdoc/>
public void Init(IServiceCollection serviceCollection)
{
@@ -496,6 +521,14 @@ namespace Emby.Server.Implementations
serviceCollection.AddSingleton<ListenBrainzLabsClient>();
serviceCollection.AddSingleton<ListenBrainzSimilarArtistProvider>();
// register the generic local metadata provider for comic files
serviceCollection.AddSingleton<ComicProvider>();
// register the actual implementations of the local metadata provider for comic files
serviceCollection.AddSingleton<IComicProvider, ComicBookInfoProvider>();
serviceCollection.AddSingleton<IComicProvider, ExternalComicInfoProvider>();
serviceCollection.AddSingleton<IComicProvider, InternalComicInfoProvider>();
serviceCollection.AddSingleton(NetManager);
serviceCollection.AddSingleton<ITaskManager, TaskManager>();

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,51 +28,23 @@ 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;
if (viewType == CollectionType.music)
{
// Music albums usually don't have dedicated backdrops, so use artist instead
includeItemTypes = [BaseItemKind.MusicArtist];
}
return view.GetItemList(new InternalItemsQuery
{
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

@@ -3886,5 +3886,17 @@ namespace Emby.Server.Implementations.Library
{
return _mediaStreamRepository.GetMediaStreamLanguages(mediaStreamType);
}
/// <inheritdoc />
public IReadOnlyList<string> GetMediaStreamLanguages(MediaStreamType mediaStreamType, InternalItemsQuery query)
{
if (query.User is not null)
{
AddUserToQuery(query, query.User);
}
SetTopParentOrAncestorIds(query);
return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType);
}
}
}

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

@@ -29,17 +29,41 @@ namespace Emby.Server.Implementations.Library
throw new ArgumentException("String can't be empty.", nameof(attribute));
}
var attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
// Must be at least 3 characters after the attribute =, ], any character,
// then we offset it by 1, because we want the index and not length.
var maxIndex = str.Length - attribute.Length - 2;
while (attributeIndex > -1 && attributeIndex < maxIndex)
// Allow tmdb as an alias for tmdbid, tvdb for tvdbid, etc.
// The code below only supports aliases for attributes in the form of "<alias>id".
ReadOnlySpan<char> shortAttr = attribute switch
{
var attributeEnd = attributeIndex + attribute.Length;
_ when attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase) => "tmdb",
_ when attribute.Equals("tvdbid", StringComparison.OrdinalIgnoreCase) => "tvdb",
_ when attribute.Equals("imdbid", StringComparison.OrdinalIgnoreCase) => "imdb",
_ => ReadOnlySpan<char>.Empty
};
for (int strIndex = 0, attributeIndex = 0; attributeIndex > -1;)
{
// We may want to use imdbid pattern matching later, so we don't want to modify the original 'str'.
var subStr = str[strIndex..];
int attributeEnd = 0;
if (shortAttr.Length > 0)
{
// If we are using an alias it should be shorter (and a prefix), so let's search for that.
attributeIndex = subStr.IndexOf(shortAttr, StringComparison.OrdinalIgnoreCase);
attributeEnd = attributeIndex + shortAttr.Length;
}
else
{
attributeIndex = subStr.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
attributeEnd = attributeIndex + attribute.Length;
}
// The next iteration should start at the end of the attribute we just found.
// If attributeIndex < 0, the loop will end and strIndex won't be used again.
strIndex += attributeEnd;
if (attributeIndex > 0)
{
var attributeOpener = str[attributeIndex - 1];
var attributeOpener = subStr[attributeIndex - 1];
var attributeCloser = attributeOpener switch
{
'[' => ']',
@@ -47,20 +71,37 @@ namespace Emby.Server.Implementations.Library
'{' => '}',
_ => '\0'
};
if (attributeCloser != '\0' && (str[attributeEnd] == '=' || str[attributeEnd] == '-'))
{
var closingIndex = str[attributeEnd..].IndexOf(attributeCloser);
// Must be at least 1 character before the closing bracket.
if (closingIndex > 1)
if (attributeCloser != '\0')
{
if (shortAttr.Length > 0
&& attributeEnd + 1 < subStr.Length
&& (subStr[attributeEnd] is 'i' or 'I')
&& (subStr[attributeEnd + 1] is 'd' or 'D'))
{
return str[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim().ToString();
// We were searching for a shortened attribute, but it's followed by "id" - let's skip it.
attributeEnd += 2;
}
// attributeEnd points at '='.
// We need at least 1 more character and the closing bracket after that.
if (attributeEnd + 2 < subStr.Length && (subStr[attributeEnd] is '=' or '-'))
{
var closingIndex = subStr[attributeEnd..].IndexOf(attributeCloser);
// Must be at least 1 character before the closing bracket.
if (closingIndex > 1)
{
var trimmed = subStr[(attributeEnd + 1)..(attributeEnd + closingIndex)].Trim();
if (trimmed.Length > 0)
{
return trimmed.ToString();
}
}
}
}
}
str = str[attributeEnd..];
attributeIndex = str.IndexOf(attribute, StringComparison.OrdinalIgnoreCase);
}
// for imdbid we also accept pattern matching
@@ -70,16 +111,6 @@ namespace Emby.Server.Implementations.Library
return match ? imdbId.ToString() : null;
}
// Allow tmdb as an alias for tmdbid
if (attribute.Equals("tmdbid", StringComparison.OrdinalIgnoreCase))
{
var tmdbValue = str.GetAttributeValue("tmdb");
if (tmdbValue is not null)
{
return tmdbValue;
}
}
return null;
}

View File

@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using Jellyfin.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
@@ -43,7 +44,19 @@ public class PathManager : IPathManager
public string? GetAttachmentPath(string mediaSourceId, string fileName)
{
var folder = GetAttachmentFolderPath(mediaSourceId);
return folder is null ? null : Path.Combine(folder, fileName);
if (folder is null)
{
return null;
}
var safeName = PathHelper.GetSafeLeafFileName(fileName);
if (safeName is null)
{
_logger.LogWarning("Rejecting attachment filename '{FileName}' for MediaSource {MediaSourceId}: not a valid leaf name.", fileName, mediaSourceId);
return null;
}
return Path.Combine(folder, safeName);
}
/// <inheritdoc />

View File

@@ -376,15 +376,24 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies
// We need to only look at the name of this actual item (not parents)
var justName = item.IsInMixedFolder ? Path.GetFileName(item.Path.AsSpan()) : Path.GetFileName(item.ContainingFolderPath.AsSpan());
var tmdbid = justName.GetAttributeValue("tmdbid");
// The fallback filename is only used when the item isn't in a mixed folder
var fileName = item.IsInMixedFolder ? ReadOnlySpan<char>.Empty : Path.GetFileName(item.Path.AsSpan());
// If not in a mixed folder and ID not found in folder path, check filename
if (string.IsNullOrEmpty(tmdbid) && !item.IsInMixedFolder)
item.TrySetProviderId(MetadataProvider.Tmdb, GetIdFromNameOrPath(justName, fileName, "tmdbid"));
item.TrySetProviderId(MetadataProvider.Tvdb, GetIdFromNameOrPath(justName, fileName, "tvdbid"));
string GetIdFromNameOrPath(ReadOnlySpan<char> name, ReadOnlySpan<char> fallbackName, string attribute)
{
tmdbid = Path.GetFileName(item.Path.AsSpan()).GetAttributeValue("tmdbid");
}
var id = name.GetAttributeValue(attribute);
item.TrySetProviderId(MetadataProvider.Tmdb, tmdbid);
// If not in a mixed folder and ID not found in folder path, check filename
if (string.IsNullOrEmpty(id) && !item.IsInMixedFolder)
{
id = fallbackName.GetAttributeValue(attribute);
}
return id;
}
if (!string.IsNullOrEmpty(item.Path))
{

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

@@ -192,7 +192,8 @@ namespace Emby.Server.Implementations.Library
}
else
{
var userData = item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault();
var userDataRow = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
var userData = userDataRow is not null ? Map(userDataRow) : null;
if (userData is not null)
{
result[item.Id] = userData;
@@ -211,36 +212,128 @@ namespace Emby.Server.Implementations.Library
return result;
}
// Build a single query for all missing items
// Build a single query for all missing items. Fetch rows by item alone so rows kept
// under keys from older metadata resolve the same way as the in-memory path.
var allItemIds = itemsNeedingQuery.Select(x => x.Item.Id).ToList();
var allKeys = itemsNeedingQuery.SelectMany(x => x.Keys).Distinct().ToList();
if (allKeys.Count > 0)
using var context = _repository.CreateDbContext();
var userDataArray = context.UserData
.AsNoTracking()
.Where(e => e.UserId.Equals(user.Id))
.WhereOneOrMany(allItemIds, e => e.ItemId)
.ToArray();
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
foreach (var (item, keys) in itemsNeedingQuery)
{
using var context = _repository.CreateDbContext();
var userDataArray = context.UserData
.AsNoTracking()
.Where(e => e.UserId.Equals(user.Id))
.WhereOneOrMany(allItemIds, e => e.ItemId)
.WhereOneOrMany(allKeys, e => e.CustomDataKey)
.ToArray();
var userDataByItem = userDataArray.GroupBy(e => e.ItemId).ToDictionary(g => g.Key, g => g.ToArray());
foreach (var (item, keys) in itemsNeedingQuery)
UserItemData userData;
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
{
UserItemData userData;
if (userDataByItem.TryGetValue(item.Id, out var itemUserData) && itemUserData.Length > 0)
{
var directDataReference = itemUserData.FirstOrDefault(e => e.CustomDataKey == item.Id.ToString("N"));
userData = directDataReference is not null ? Map(directDataReference) : Map(itemUserData.First());
}
else
{
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
}
userData = Map(ResolveUserDataRow(item, itemUserData)!);
}
else
{
userData = new UserItemData { Key = keys.Count > 0 ? keys[0] : string.Empty };
}
result[item.Id] = userData;
var cacheKey = GetCacheKey(user.InternalId, item.Id);
_cache.AddOrUpdate(cacheKey, userData);
result[item.Id] = userData;
var cacheKey = GetCacheKey(user.InternalId, item.Id);
_cache.AddOrUpdate(cacheKey, userData);
}
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)
.WhereOneOrMany(localProbeIds, lc => 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]);
}
}
@@ -259,12 +352,40 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc />
public UserItemData? GetUserData(User user, BaseItem item)
{
return item.UserData?.Where(e => e.UserId.Equals(user.Id)).Select(Map).FirstOrDefault() ?? new UserItemData()
var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
return row is not null ? Map(row) : new UserItemData()
{
Key = item.GetUserDataKeys()[0],
};
}
/// <summary>
/// Picks the row matching the item's current user data keys, in key order, so rows left behind
/// under keys from older metadata don't take priority over the rows the write path updates.
/// </summary>
/// <param name="item">The item whose keys to match.</param>
/// <param name="rows">The candidate user data rows for a single user.</param>
/// <returns>The best matching row, or <c>null</c> when there are none.</returns>
private static UserData? ResolveUserDataRow(BaseItem item, IEnumerable<UserData>? rows)
{
var candidates = rows?.ToList();
if (candidates is null || candidates.Count == 0)
{
return null;
}
foreach (var key in item.GetUserDataKeys())
{
var match = candidates.Find(e => string.Equals(e.CustomDataKey, key, StringComparison.Ordinal));
if (match is not null)
{
return match;
}
}
return candidates[0];
}
/// <inheritdoc />
public UserItemDataDto? GetUserDataDto(BaseItem item, User user)
=> GetUserDataDto(item, null, user, new DtoOptions());
@@ -281,6 +402,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 +510,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

@@ -0,0 +1,19 @@
{
"Books": "Kitablar",
"HomeVideos": "Ev Videoları",
"Latest": "Ən son",
"MixedContent": "Qarışıq məzmun",
"Movies": "Filmlər",
"Music": "Musiqi",
"MusicVideos": "Musiqi Videoları",
"NameSeasonUnknown": "Mövsüm Naməlum",
"NewVersionIsAvailable": "Jellyfin Serverin yeni versiyası yükləmək üçün əlçatandır.",
"NotificationOptionApplicationUpdateAvailable": "Tətbiq yeniləməsi mövcuddur",
"NotificationOptionApplicationUpdateInstalled": "Tətbiq yeniləməsi quraşdırılıb",
"NotificationOptionAudioPlayback": "Audio oxutma başladı",
"NotificationOptionAudioPlaybackStopped": "Audio oxutma dayandırıldı",
"NotificationOptionCameraImageUploaded": "Kamera şəkli yükləndi",
"NotificationOptionInstallationFailed": "Quraşdırma uğursuzluğu",
"NotificationOptionNewLibraryContent": "Yeni məzmun əlavə edildi",
"NotificationOptionPluginError": "Plugin uğursuzluğu"
}

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,41 @@
"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",
"MixedContent": "Blandað innihald",
"Music": "Tónleikur"
}

View File

@@ -106,5 +106,6 @@
"TaskRefreshTrickplayImages": "Xerar miniaturas de previsualización",
"TaskAudioNormalizationDescription": "Escanea ficheiros á procura de datos de normalización de volume.",
"CleanupUserDataTask": "Tarefa de limpeza de datos dos usuarios",
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días."
"CleanupUserDataTaskDescription": "Limpa todos os datos do usuario (estado de visualización, de favorito etc.) dos medios ausentes polo menos 90 días.",
"Original": "Orixinal"
}

View File

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

View File

@@ -107,5 +107,6 @@
"TaskMoveTrickplayImagesDescription": "Premješta postojeće datoteke brzog pregledavanja u postavke biblioteke.",
"CleanupUserDataTask": "Zadatak čišćenja korisničkih podataka",
"CleanupUserDataTaskDescription": "Briše sve korisničke podatke (stanje gledanja, status favorita itd.) s medija koji više nisu prisutni najmanje 90 dana.",
"Original": "Original"
"Original": "Original",
"LyricDownloadFailureFromForItem": "Preuzimanje tekstova pjesmi od {0} za {1} nije uspjelo"
}

View File

@@ -103,5 +103,10 @@
"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.",
"TaskMoveTrickplayImages": "Flytja geymslustað fyrir Trickplay-myndir",
"TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins."
}

View File

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

View File

@@ -106,5 +106,7 @@
"TaskDownloadMissingLyrics": "누락된 가사 다운로드",
"TaskDownloadMissingLyricsDescription": "가사 다운로드",
"CleanupUserDataTask": "사용자 데이터 정리 작업",
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다."
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.",
"LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다",
"Original": "원본"
}

View File

@@ -107,5 +107,6 @@
"TaskDownloadMissingLyricsDescription": "Lejupielādēt vārdus dziesmām",
"CleanupUserDataTask": "Lietotāju datu tīrīšanas uzdevums",
"CleanupUserDataTaskDescription": "Notīra visus lietotāja datus (skatīšanās stāvokļus, favorītu statusi utt.) no medijiem, kas vairs nav pieejami vismaz 90 dienas.",
"Original": "Oriģināls"
"Original": "Oriģināls",
"LyricDownloadFailureFromForItem": "Dziesmu vārdi nevarēja tikt lejupielādēti no {0} priekš {1}"
}

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 @@
"TaskDownloadMissingLyrics": "Stiahnuť chýbajúce texty piesní",
"TaskDownloadMissingLyricsDescription": "Stiahne texty pre piesne",
"CleanupUserDataTask": "Prečistiť používateľské dáta",
"CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní."
"CleanupUserDataTaskDescription": "Vyčistí všetky dáta používateľa (stav sledovania, stav obľúbených atď.) z médií, ktoré už neexistujú aspoň 90 dní.",
"LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}",
"Original": "Originál"
}

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

@@ -1 +1,5 @@
{}
{
"Artists": "Wasanii",
"Books": "Vitabu",
"Collections": "Mikusanyiko"
}

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

@@ -261,6 +261,24 @@ namespace Emby.Server.Implementations.Localization
_cultures);
}
/// <inheritdoc />
public string? GetLanguageDisplayName(string language)
{
if (string.IsNullOrEmpty(language))
{
return null;
}
var displayName = FindLanguageInfo(language)?.DisplayName;
if (displayName is null)
{
return null;
}
// Truncate at the first delimiter to avoid cluttered display names
return displayName.Split([';', ','], StringSplitOptions.None)[0].Trim();
}
/// <inheritdoc />
public IReadOnlyList<CountryInfo> GetCountries()
{
@@ -356,6 +374,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 +403,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 +505,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 +526,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)
{
@@ -566,11 +624,15 @@ namespace Emby.Server.Implementations.Localization
private static string GetResourceFilename(string culture)
{
var parts = culture.Split('-');
// Region codes may use a '-' (BCP-47, e.g. "pt-BR") or '_' (e.g. "es_419", "ar_SA") separator.
// Normalize the casing (lower-case language, upper-case region) while preserving the separator
// so the result matches the embedded resource file name, which is case-sensitive.
var separatorIndex = culture.IndexOfAny(['-', '_']);
if (parts.Length == 2)
if (separatorIndex > 0)
{
culture = parts[0].ToLowerInvariant() + "-" + parts[1].ToUpperInvariant();
var separator = culture[separatorIndex];
culture = culture[..separatorIndex].ToLowerInvariant() + separator + culture[(separatorIndex + 1)..].ToUpperInvariant();
}
else
{

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

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
@@ -17,6 +18,7 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
private readonly ILogger<OptimizeDatabaseTask> _logger;
private readonly ILocalizationManager _localization;
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
private readonly ILibraryManager _libraryManager;
/// <summary>
/// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class.
@@ -24,14 +26,17 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
/// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param>
/// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param>
/// <param name="jellyfinDatabaseProvider">Instance of the JellyfinDatabaseProvider that can be used for provider specific operations.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
public OptimizeDatabaseTask(
ILogger<OptimizeDatabaseTask> logger,
ILocalizationManager localization,
IJellyfinDatabaseProvider jellyfinDatabaseProvider)
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
ILibraryManager libraryManager)
{
_logger = logger;
_localization = localization;
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
_libraryManager = libraryManager;
}
/// <inheritdoc />
@@ -68,6 +73,15 @@ public class OptimizeDatabaseTask : IScheduledTask, IConfigurableScheduledTask
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
// Vacuuming/checkpointing requires an exclusive lock on the database. Running it while a library scan is in
// progress causes both operations to contend for the database and can stall the scan, so defer optimization
// until no scan is running. The task will run again on its next trigger.
if (_libraryManager.IsScanRunning)
{
_logger.LogInformation("Skipping database optimization because a library scan is currently running.");
return;
}
_logger.LogInformation("Optimizing and vacuuming jellyfin.db...");
try

View File

@@ -4,11 +4,18 @@ 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;
namespace Emby.Server.Implementations.ScheduledTasks.Tasks;
@@ -20,6 +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.
@@ -27,11 +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>
public PeopleValidationTask(ILibraryManager libraryManager, ILocalizationManager localization, IDbContextFactory<JellyfinDbContext> dbContextFactory)
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="logger">Instance of the <see cref="ILogger{PeopleValidationTask}"/> interface.</param>
/// <param name="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 />
@@ -71,13 +93,19 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
IProgress<double> subProgress = new Progress<double>((val) => progress.Report(val / 2));
await _libraryManager.ValidatePeopleAsync(subProgress, cancellationToken).ConfigureAwait(false);
// People validation performs heavy database writes that contend with an active library scan.
// Defer it until the scan has finished; the task will run again on its next trigger.
if (_libraryManager.IsScanRunning)
{
_logger.LogInformation("Skipping people validation because a library scan is currently running.");
return;
}
subProgress = new Progress<double>((val) => progress.Report((val / 2) + 50));
// 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 / 3));
var dupQuery = context.Peoples
.GroupBy(e => new { e.Name, e.PersonType })
.Where(e => e.Count() > 1)
@@ -123,7 +151,113 @@ public class PeopleValidationTask : IScheduledTask, IConfigurableScheduledTask
ArrayPool<Guid[]>.Shared.Return(buffer);
}
var peopleToDelete = await context.Peoples
.Where(p => !context.PeopleBaseItemMap.Any(m => m.PeopleId.Equals(p.Id)))
.ExecuteDeleteAsync(cancellationToken)
.ConfigureAwait(false);
_logger.LogInformation("Removed {Count} orphaned people.", peopleToDelete);
subProgress.Report(100);
}
// 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

@@ -343,6 +343,10 @@ namespace Emby.Server.Implementations.Session
_activeLiveStreamSessions.TryRemove(liveStreamId, out _);
}
}
else
{
liveStreamNeedsToBeClosed = true;
}
if (liveStreamNeedsToBeClosed)
{
@@ -725,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>
@@ -756,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);
}
}
@@ -890,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);
}
}
@@ -952,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);
}
}
@@ -1083,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);
}
}
@@ -1138,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

@@ -1,4 +1,5 @@
using System;
using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
@@ -32,6 +33,8 @@ namespace Emby.Server.Implementations.Updates
/// </summary>
public class InstallationManager : IInstallationManager
{
private static readonly SearchValues<char> InvalidPackageNameChars = SearchValues.Create([.. Path.GetInvalidFileNameChars(), '/', '\\']);
/// <summary>
/// The logger.
/// </summary>
@@ -521,9 +524,27 @@ namespace Emby.Server.Implementations.Updates
return;
}
if (!IsValidPackageDirectoryName(package.Name))
{
_logger.LogError("Refusing to install package with invalid name {PackageName}.", package.Name);
throw new InvalidDataException($"Plugin package name '{package.Name}' is not a valid directory name.");
}
// Always override the passed-in target (which is a file) and figure it out again
string targetDir = Path.Combine(_appPaths.PluginsPath, package.Name);
var pluginsRoot = Path.TrimEndingDirectorySeparator(Path.GetFullPath(_appPaths.PluginsPath));
var resolvedTarget = Path.GetFullPath(targetDir);
if (!resolvedTarget.StartsWith(pluginsRoot + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase))
{
_logger.LogError(
"Refusing to install package {PackageName}: resolved target {Resolved} is outside plugins directory {Root}.",
package.Name,
resolvedTarget,
pluginsRoot);
throw new InvalidDataException($"Plugin package name '{package.Name}' resolves outside the plugins directory.");
}
using var response = await _httpClientFactory.CreateClient(NamedClient.Default)
.GetAsync(new Uri(package.SourceUrl), cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
@@ -572,6 +593,26 @@ namespace Emby.Server.Implementations.Updates
_pluginManager.ImportPluginFrom(targetDir);
}
private static bool IsValidPackageDirectoryName(string? name)
{
if (string.IsNullOrWhiteSpace(name))
{
return false;
}
if (name.Equals(".", StringComparison.Ordinal) || name.Equals("..", StringComparison.Ordinal))
{
return false;
}
if (name.IndexOfAny(InvalidPackageNameChars) >= 0)
{
return false;
}
return true;
}
private async Task<bool> InstallPackageInternal(InstallationInfo package, CancellationToken cancellationToken)
{
LocalPlugin? plugin = _pluginManager.Plugins.FirstOrDefault(p => p.Id.Equals(package.Id) && p.Version.Equals(package.Version))

View File

@@ -1,6 +1,6 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using Jellyfin.Api.Helpers;
using Jellyfin.Data.Enums;
using Jellyfin.Data.Queries;
using Jellyfin.Database.Implementations.Enums;
@@ -84,36 +84,9 @@ public class ActivityLogController : BaseJellyfinApiController
ItemId = itemId,
Username = username,
Severity = severity,
OrderBy = GetOrderBy(sortBy ?? [], sortOrder ?? []),
OrderBy = RequestHelpers.GetOrderBy(sortBy ?? [], sortOrder ?? []),
};
return await _activityManager.GetPagedResultAsync(query).ConfigureAwait(false);
}
private static (ActivityLogSortBy SortBy, SortOrder SortOrder)[] GetOrderBy(
IReadOnlyList<ActivityLogSortBy> sortBy,
IReadOnlyList<SortOrder> requestedSortOrder)
{
if (sortBy.Count == 0)
{
return [];
}
var result = new (ActivityLogSortBy, SortOrder)[sortBy.Count];
var i = 0;
for (; i < requestedSortOrder.Count; i++)
{
result[i] = (sortBy[i], requestedSortOrder[i]);
}
// Add remaining elements with the first specified SortOrder
// or the default one if no SortOrders are specified
var order = requestedSortOrder.Count > 0 ? requestedSortOrder[0] : SortOrder.Ascending;
for (; i < sortBy.Count; i++)
{
result[i] = (sortBy[i], order);
}
return result;
}
}

View File

@@ -158,13 +158,43 @@ public class FilterController : BaseJellyfinApiController
IsSeries = isSeries
};
var streamLanguageQuery = new InternalItemsQuery(user)
{
// It's possible that different langauges are only available on alternative versions.
// To fetch them all, owned items are included.
IncludeOwnedItems = true,
IncludeItemTypes = includeItemTypes,
DtoOptions = new DtoOptions
{
Fields = Array.Empty<ItemFields>(),
EnableImages = false,
EnableUserData = false
},
IsAiring = isAiring,
IsMovie = isMovie,
IsSports = isSports,
IsKids = isKids,
IsNews = isNews,
IsSeries = isSeries
};
if ((recursive ?? true) || parentItem is UserView || parentItem is ICollectionFolder)
{
genreQuery.AncestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id };
var ancestorIds = parentItem is null ? Array.Empty<Guid>() : new[] { parentItem.Id };
genreQuery.AncestorIds = ancestorIds;
streamLanguageQuery.AncestorIds = ancestorIds;
}
else
{
genreQuery.Parent = parentItem;
streamLanguageQuery.Parent = parentItem;
}
if ((includeItemTypes.Contains(BaseItemKind.Series) || includeItemTypes.Contains(BaseItemKind.Season))
&& !includeItemTypes.Contains(BaseItemKind.Episode))
{
// streams are joined on epsiodes not shows or seasons
streamLanguageQuery.IncludeItemTypes = [.. includeItemTypes, BaseItemKind.Episode];
}
if (includeItemTypes.Length == 1
@@ -188,10 +218,13 @@ public class FilterController : BaseJellyfinApiController
}).ToArray();
}
if (includeItemTypes.Contains(BaseItemKind.Movie) || includeItemTypes.Contains(BaseItemKind.Series))
if (includeItemTypes.Contains(BaseItemKind.Movie)
|| includeItemTypes.Contains(BaseItemKind.Series)
|| includeItemTypes.Contains(BaseItemKind.Season)
|| includeItemTypes.Contains(BaseItemKind.Episode))
{
filters.AudioLanguages = _libraryManager
.GetMediaStreamLanguages(MediaStreamType.Audio)
.GetMediaStreamLanguages(MediaStreamType.Audio, streamLanguageQuery)
.Select(language =>
{
var culture = _localization.FindLanguageInfo(language);
@@ -204,7 +237,7 @@ public class FilterController : BaseJellyfinApiController
.OrderBy(l => l.Name)
.ToArray();
filters.SubtitleLanguages = _libraryManager
.GetMediaStreamLanguages(MediaStreamType.Subtitle)
.GetMediaStreamLanguages(MediaStreamType.Subtitle, streamLanguageQuery)
.Select(language =>
{
var culture = _localization.FindLanguageInfo(language);

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

@@ -125,7 +125,13 @@ public class ImageController : BaseJellyfinApiController
{
// Handle image/png; charset=utf-8
var mimeType = Request.ContentType?.Split(';').FirstOrDefault();
var userDataPath = Path.Combine(_serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath, user.Username);
var userConfigurationDirectoryPath = _serverConfigurationManager.ApplicationPaths.UserConfigurationDirectoryPath;
var userDataPath = Path.Combine(userConfigurationDirectoryPath, user.Username);
if (!PathHelper.IsContainedIn(userConfigurationDirectoryPath, userDataPath))
{
return BadRequest("Invalid user.");
}
if (user.ProfileImage is not null)
{
await _userManager.ClearProfileImageAsync(user).ConfigureAwait(false);
@@ -2049,7 +2055,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

@@ -236,7 +236,7 @@ public class ItemUpdateController : BaseJellyfinApiController
return NoContent();
}
private async Task UpdateItem(BaseItemDto request, BaseItem item)
internal async Task UpdateItem(BaseItemDto request, BaseItem item)
{
item.Name = request.Name;
item.ForcedSortName = request.ForcedSortName;
@@ -250,7 +250,11 @@ public class ItemUpdateController : BaseJellyfinApiController
item.IndexNumber = request.IndexNumber;
item.ParentIndexNumber = request.ParentIndexNumber;
item.Overview = request.Overview;
item.Genres = request.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
if (request.Genres is not null)
{
item.Genres = request.Genres.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
if (item is Episode episode)
{
@@ -279,6 +283,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;
@@ -288,15 +297,27 @@ public class ItemUpdateController : BaseJellyfinApiController
item.CustomRating = request.CustomRating;
var currentTags = item.Tags;
var newTags = request.Tags.Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
var removedTags = currentTags.Except(newTags).ToList();
var addedTags = newTags.Except(currentTags).ToList();
item.Tags = newTags;
List<string> removedTags;
List<string> addedTags;
if (request.Tags is not null)
{
var newTags = request.Tags.Select(t => t.Trim()).Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
removedTags = currentTags.Except(newTags).ToList();
addedTags = newTags.Except(currentTags).ToList();
item.Tags = newTags;
}
else
{
removedTags = [];
addedTags = [];
}
if (item is Series rseries)
{
foreach (var season in rseries.Children.OfType<Season>())
{
season.SeriesName = rseries.Name;
if (!season.LockedFields.Contains(MetadataField.OfficialRating))
{
season.OfficialRating = request.OfficialRating;
@@ -314,6 +335,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;
@@ -403,15 +426,18 @@ public class ItemUpdateController : BaseJellyfinApiController
item.RunTimeTicks = request.RunTimeTicks;
}
foreach (var pair in request.ProviderIds.ToList())
if (request.ProviderIds is not null)
{
if (string.IsNullOrEmpty(pair.Value))
foreach (var pair in request.ProviderIds.ToList())
{
request.ProviderIds.Remove(pair.Key);
if (string.IsNullOrEmpty(pair.Value))
{
request.ProviderIds.Remove(pair.Key);
}
}
}
item.ProviderIds = request.ProviderIds;
item.ProviderIds = request.ProviderIds;
}
if (item is Video video)
{

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

@@ -1002,9 +1002,7 @@ public class LiveTvController : BaseJellyfinApiController
{
if (!string.IsNullOrEmpty(pw))
{
// TODO: remove ToLower when Convert.ToHexString supports lowercase
// Schedules Direct requires the hex to be lowercase
listingsProviderInfo.Password = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(pw))).ToLowerInvariant();
listingsProviderInfo.Password = Convert.ToHexStringLower(SHA1.HashData(Encoding.UTF8.GetBytes(pw)));
}
return await _listingsManager.SaveListingProvider(listingsProviderInfo, validateLogin, validateListings).ConfigureAwait(false);

View File

@@ -6,6 +6,7 @@ using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
using Jellyfin.Api.Attributes;
using Jellyfin.Extensions;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Api;
using MediaBrowser.Common.Plugins;
@@ -226,10 +227,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

@@ -140,6 +140,11 @@ public class StartupController : BaseJellyfinApiController
return NotFound();
}
if (!string.IsNullOrEmpty(user.Password))
{
return Forbid();
}
if (string.IsNullOrWhiteSpace(startupUserDto.Password))
{
return BadRequest("Password must not be empty");

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];
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

@@ -5,7 +5,6 @@ using System.Security.Claims;
using System.Threading.Tasks;
using Jellyfin.Api.Constants;
using Jellyfin.Api.Extensions;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
@@ -31,15 +30,16 @@ public static class RequestHelpers
/// </summary>
/// <param name="sortBy">Sort By. Comma delimited string.</param>
/// <param name="requestedSortOrder">Sort Order. Comma delimited string.</param>
/// <typeparam name="TSortBy">The type of the sort by field.</typeparam>
/// <returns>Order By.</returns>
public static (ItemSortBy, SortOrder)[] GetOrderBy(IReadOnlyList<ItemSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder)
public static (TSortBy, SortOrder)[] GetOrderBy<TSortBy>(IReadOnlyList<TSortBy> sortBy, IReadOnlyList<SortOrder> requestedSortOrder)
{
if (sortBy.Count == 0)
{
return Array.Empty<(ItemSortBy, SortOrder)>();
return Array.Empty<(TSortBy, SortOrder)>();
}
var result = new (ItemSortBy, SortOrder)[sortBy.Count];
var result = new (TSortBy, SortOrder)[sortBy.Count];
var i = 0;
// Add elements which have a SortOrder specified
for (; i < requestedSortOrder.Count; i++)

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

@@ -12,6 +12,7 @@ using Jellyfin.Database.Implementations;
using Jellyfin.Server.Implementations.StorageHelpers;
using Jellyfin.Server.Implementations.SystemBackupService;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.SystemBackupService;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -33,6 +34,7 @@ public class BackupService : IBackupService
private readonly IServerApplicationPaths _applicationPaths;
private readonly IJellyfinDatabaseProvider _jellyfinDatabaseProvider;
private readonly IHostApplicationLifetime _hostApplicationLifetime;
private readonly ILibraryManager _libraryManager;
private static readonly JsonSerializerOptions _serializerSettings = new JsonSerializerOptions(JsonSerializerDefaults.General)
{
AllowTrailingCommas = true,
@@ -50,13 +52,15 @@ public class BackupService : IBackupService
/// <param name="applicationPaths">The application paths.</param>
/// <param name="jellyfinDatabaseProvider">The Jellyfin database Provider in use.</param>
/// <param name="applicationLifetime">The SystemManager.</param>
/// <param name="libraryManager">Instance of the <see cref="ILibraryManager"/> interface.</param>
public BackupService(
ILogger<BackupService> logger,
IDbContextFactory<JellyfinDbContext> dbProvider,
IServerApplicationHost applicationHost,
IServerApplicationPaths applicationPaths,
IJellyfinDatabaseProvider jellyfinDatabaseProvider,
IHostApplicationLifetime applicationLifetime)
IHostApplicationLifetime applicationLifetime,
ILibraryManager libraryManager)
{
_logger = logger;
_dbProvider = dbProvider;
@@ -64,6 +68,7 @@ public class BackupService : IBackupService
_applicationPaths = applicationPaths;
_jellyfinDatabaseProvider = jellyfinDatabaseProvider;
_hostApplicationLifetime = applicationLifetime;
_libraryManager = libraryManager;
}
/// <inheritdoc/>
@@ -263,6 +268,14 @@ public class BackupService : IBackupService
/// <inheritdoc/>
public async Task<BackupManifestDto> CreateBackupAsync(BackupOptionsDto backupOptions)
{
// Creating a backup runs a database optimization and reads the entire database under a transaction, both of
// which heavily contend with an active library scan and could capture an inconsistent database state.
if (_libraryManager.IsScanRunning)
{
_logger.LogWarning("Cannot create a backup while a library scan is running.");
throw new InvalidOperationException("Cannot create a backup while a library scan is running. Please try again once the scan has finished.");
}
var manifest = new BackupManifest()
{
DateCreated = DateTime.UtcNow,
@@ -346,18 +359,39 @@ public class BackupService : IBackupService
jsonSerializer.WriteStartArray();
var set = entityType.ValueFactory().ConfigureAwait(false);
await foreach (var item in set.ConfigureAwait(false))
var enumerator = set.GetAsyncEnumerator();
await using (enumerator)
{
entities++;
try
while (true)
{
using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
document.WriteTo(jsonSerializer);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not load entity {Entity}", item);
throw;
bool hasNext;
try
{
hasNext = await enumerator.MoveNextAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not read next entity of type {Table}, the underlying data appears to be corrupt. Skipping this row and continuing backup; the affected database row should be inspected and fixed manually", entityType.SourceName);
continue;
}
if (!hasNext)
{
break;
}
var item = enumerator.Current;
entities++;
try
{
using var document = JsonSerializer.SerializeToDocument(item, _serializerSettings);
document.WriteTo(jsonSerializer);
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not load entity {Entity}", item);
throw;
}
}
}

View File

@@ -7,6 +7,7 @@ using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Querying;
using Microsoft.EntityFrameworkCore;
using BaseItemDto = MediaBrowser.Controller.Entities.BaseItem;
@@ -81,6 +82,41 @@ public sealed partial class BaseItemRepository
_itemTypeLookup.MusicGenreTypes);
}
/// <inheritdoc />
public IReadOnlyList<string> GetMediaStreamLanguages(InternalItemsQuery filter, MediaStreamType mediaStreamType)
{
ArgumentNullException.ThrowIfNull(filter);
using var context = _dbProvider.CreateDbContext();
return TranslateQuery(
context.BaseItems.Include(e => e.MediaStreams).Where(e => e.Id != EF.Constant(PlaceholderId)),
context,
new InternalItemsQuery(filter.User)
{
IncludeOwnedItems = filter.IncludeOwnedItems,
ExcludeItemTypes = filter.ExcludeItemTypes,
IncludeItemTypes = filter.IncludeItemTypes,
MediaTypes = filter.MediaTypes,
AncestorIds = filter.AncestorIds,
ItemIds = filter.ItemIds,
TopParentIds = filter.TopParentIds,
ParentId = filter.ParentId,
IsAiring = filter.IsAiring,
IsMovie = filter.IsMovie,
IsSports = filter.IsSports,
IsKids = filter.IsKids,
IsNews = filter.IsNews,
IsSeries = filter.IsSeries
})
.Where(e => e.MediaStreams != null)
.SelectMany(e => e.MediaStreams!)
.Where(e => e.StreamType == (MediaStreamTypeEntity)mediaStreamType)
.Select(s => string.IsNullOrEmpty(s.Language) ? "und" : s.Language) // und = undetermined
.Distinct()
.ToArray();
}
private string[] GetItemValueNames(IReadOnlyList<ItemValueType> itemValueTypes, IReadOnlyList<string> withItemTypes, IReadOnlyList<string> excludeItemTypes)
{
using var context = _dbProvider.CreateDbContext();

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)
@@ -444,6 +447,7 @@ public sealed partial class BaseItemRepository
if (filter.IncludeInheritedTags.Length > 0)
{
var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray();
var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var allowedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
@@ -452,7 +456,10 @@ public sealed partial class BaseItemRepository
allowedTagItemIds.Contains(e.Id)
|| (e.SeriesId.HasValue && allowedTagItemIds.Contains(e.SeriesId.Value))
|| e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
|| (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value)));
|| (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))
// People don't carry the tags of the media they appear in and would never match
|| e.Type == personTypeName);
}
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.

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,49 @@ 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.
// Only in-progress siblings can eliminate a candidate: a version without progress has a NULL max LastPlayedDate,
// which is never greater and never ties. Restricting the sibling scan to the in-progress set keeps this bounded by
// the user's Continue Watching count instead of forcing a full BaseItems scan (COALESCE keys are non-indexable) per row.
baseQuery = baseQuery.Where(e => e.Type == seriesTypeName || !context.BaseItems
.Where(s => s.Id != e.Id
&& inProgressIds.Contains(s.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 +768,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)
@@ -1059,6 +1089,7 @@ public sealed partial class BaseItemRepository
{
var includeTags = filter.IncludeInheritedTags.Select(e => e.GetCleanValue()).ToArray();
var isPlaylistOnlyQuery = includeTypes.Length == 1 && includeTypes.FirstOrDefault() == BaseItemKind.Playlist;
var personTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var allowedTagItemIds = context.ItemValuesMap
.Where(f => f.ItemValue.Type == ItemValueType.Tags && includeTags.Contains(f.ItemValue.CleanValue))
.Select(f => f.ItemId);
@@ -1069,6 +1100,9 @@ public sealed partial class BaseItemRepository
|| e.Parents!.Any(p => allowedTagItemIds.Contains(p.ParentItemId))
|| (e.TopParentId.HasValue && allowedTagItemIds.Contains(e.TopParentId.Value))
// People don't carry the tags of the media they appear in and would never match
|| e.Type == personTypeName
// A playlist should be accessible to its owner regardless of allowed tags
|| (isPlaylistOnlyQuery && e.Data!.Contains($"OwnerUserId\":\"{filter.User!.Id:N}\"")));
}

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

@@ -65,8 +65,13 @@ public class ItemPersistenceService : IItemPersistenceService
descendantIds.Add(id);
}
// Use WhereOneOrMany instead of a raw HashSet.Contains so large id sets are bound as a
// single parameter (json_each) rather than one SQL variable per id, which would otherwise
// overflow SQLite's variable limit when deleting many items at once (e.g. migrations).
var ownerIds = descendantIds.ToArray();
var extraIds = context.BaseItems
.Where(e => e.OwnerId.HasValue && descendantIds.Contains(e.OwnerId.Value))
.Where(e => e.OwnerId.HasValue)
.WhereOneOrMany(ownerIds, e => e.OwnerId!.Value)
.Select(e => e.Id)
.ToArray();

View File

@@ -172,8 +172,7 @@ public class MediaStreamRepository : IMediaStreamRepository
if (!string.IsNullOrEmpty(dto.Language))
{
var culture = _localization.FindLanguageInfo(dto.Language);
dto.LocalizedLanguage = culture?.DisplayName;
dto.LocalizedLanguage = _localization.GetLanguageDisplayName(dto.Language);
}
if (dto.Type is MediaStreamType.Audio)

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.");
}
@@ -616,6 +616,12 @@ namespace Jellyfin.Server.Implementations.Users
.SetProperty(f => f.LastActivityDate, date)
.SetProperty(f => f.LastLoginDate, date))
.ConfigureAwait(false);
// ExecuteUpdateAsync bypasses the change tracker, so keep the
// returned entity in sync. Otherwise SessionManager.LogSessionActivity
// saves this (stale) entity in full and reverts LastLoginDate.
user.LastActivityDate = date;
user.LastLoginDate = date;
}
await dbContext.Users
@@ -631,6 +637,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);
@@ -882,8 +889,20 @@ namespace Jellyfin.Server.Implementations.Users
var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
dbContext.Remove(user.ProfileImage);
await dbContext.SaveChangesAsync().ConfigureAwait(false);
// Remove the tracked profile image loaded from the database instead of the
// detached instance on the passed in user. That instance can carry a stale,
// never-persisted (temporary) key, which makes EF Core throw when it is marked
// for deletion, leaving the profile image impossible to clear or replace.
var dbUser = await UserQuery(dbContext)
.AsTracking()
.FirstOrDefaultAsync(u => u.Id == user.Id)
.ConfigureAwait(false);
if (dbUser?.ProfileImage is not null)
{
dbContext.Remove(dbUser.ProfileImage);
dbUser.ProfileImage = null;
await dbContext.SaveChangesAsync().ConfigureAwait(false);
}
}
user.ProfileImage = null;
@@ -892,7 +911,7 @@ namespace Jellyfin.Server.Implementations.Users
internal static void ThrowIfInvalidUsername(string name)
{
if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name))
if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name) && !string.Equals(name, ".", StringComparison.Ordinal) && !string.Equals(name, "..", StringComparison.Ordinal))
{
return;
}

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

@@ -215,8 +215,11 @@ internal class JellyfinMigrationService
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingCodeMigrations.Length, stage);
migrations = pendingMigrations.OrderBy(e => e.Key).ToArray();
var migrationIndex = 0;
foreach (var item in migrations)
{
// Surface generic "Running migration X of Y" progress in the always-visible startup UI header.
SetupServer.ReportActivity(StartupActivity.Migration(++migrationIndex, migrations.Length));
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
try
{

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

@@ -76,25 +76,36 @@ public class CleanupOrphanedExtras : IAsyncMigrationRoutine
_logger.LogInformation("Found {Count} orphaned extras to remove", orphanedItemIds.Count);
// Batch-resolve items for metadata path cleanup, then delete all at once
var itemsToDelete = new List<BaseItem>();
foreach (var itemId in orphanedItemIds)
// Resolve items for metadata path cleanup, then delete in batches so we never issue one
// massive delete transaction and progress stays visible on large libraries.
_logger.LogInformation("Deleting {Count} orphaned extras...", orphanedItemIds.Count);
const int deleteBatchSize = 500;
var deletedSoFar = 0;
for (var offset = 0; offset < orphanedItemIds.Count; offset += deleteBatchSize)
{
itemsToDelete.Add(BaseItemMapper.DeserializeBaseItem(
new Database.Implementations.Entities.BaseItemEntity()
{
Id = itemId.Id,
Path = itemId.Path,
Type = itemId.Type
},
_logger,
null,
true)!);
cancellationToken.ThrowIfCancellationRequested();
var batch = orphanedItemIds.GetRange(offset, Math.Min(deleteBatchSize, orphanedItemIds.Count - offset));
var itemsToDelete = batch
.Select(itemId => BaseItemMapper.DeserializeBaseItem(
new Database.Implementations.Entities.BaseItemEntity()
{
Id = itemId.Id,
Path = itemId.Path,
Type = itemId.Type
},
_logger,
null,
true)!)
.ToList();
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete);
deletedSoFar += batch.Count;
_logger.LogInformation("Deleting orphaned extras: {Deleted}/{Total}", deletedSoFar, orphanedItemIds.Count);
}
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete);
_logger.LogInformation("Successfully removed {Count} orphaned extras", itemsToDelete.Count);
_logger.LogInformation("Successfully removed {Count} orphaned extras", orphanedItemIds.Count);
}
}
}

View File

@@ -136,19 +136,38 @@ public class FixIncorrectOwnerIdRelationships : IAsyncMigrationRoutine
if (allIdsToDelete.Count > 0)
{
// Batch-resolve items for metadata path cleanup, then delete all at once
var itemsToDelete = allIdsToDelete
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
_logger.LogInformation("Deleting {Count} duplicate database entries...", allIdsToDelete.Count);
// Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = allIdsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
// Delete in batches so progress is visible (item resolution and deletion can take a
// long time on large libraries) and so we never issue one massive delete transaction.
const int deleteBatchSize = 500;
var deletedSoFar = 0;
for (var offset = 0; offset < allIdsToDelete.Count; offset += deleteBatchSize)
{
_persistenceService.DeleteItem(unresolvedIds);
cancellationToken.ThrowIfCancellationRequested();
var batchIds = allIdsToDelete.GetRange(offset, Math.Min(deleteBatchSize, allIdsToDelete.Count - offset));
// Resolve items for metadata path cleanup, then delete this batch
var itemsToDelete = batchIds
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
if (itemsToDelete.Count > 0)
{
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
}
// Fall back to direct DB deletion for any items that couldn't be resolved via LibraryManager
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
{
_persistenceService.DeleteItem(unresolvedIds);
}
deletedSoFar += batchIds.Count;
_logger.LogInformation("Deleting duplicates: {Deleted}/{Total} items", deletedSoFar, allIdsToDelete.Count);
}
}

View File

@@ -182,23 +182,35 @@ public class MergeDuplicateMusicArtists : IAsyncMigrationRoutine
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
// %MetadataPath%/artists/<Name> directories that the duplicate stubs left behind.
// Fall back to the persistence service for any items the LibraryManager can't resolve.
var itemsToDelete = idsToDelete
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
if (itemsToDelete.Count > 0)
// Delete in batches so we never issue one massive delete transaction and progress stays visible.
_logger.LogInformation("Deleting {Count} duplicate MusicArtist records...", idsToDelete.Count);
const int deleteBatchSize = 500;
var deletedSoFar = 0;
for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize)
{
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
}
cancellationToken.ThrowIfCancellationRequested();
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
{
_persistenceService.DeleteItem(unresolvedIds);
}
var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset));
_logger.LogInformation("Removed {Count} duplicate MusicArtist records.", idsToDelete.Count);
var itemsToDelete = batchIds
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
if (itemsToDelete.Count > 0)
{
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
}
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
{
_persistenceService.DeleteItem(unresolvedIds);
}
deletedSoFar += batchIds.Count;
_logger.LogInformation("Deleting duplicate MusicArtist records: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count);
}
}
}
}

View File

@@ -184,23 +184,35 @@ public class MergeDuplicatePeople : IAsyncMigrationRoutine
// Resolve via LibraryManager so DeleteItemsUnsafeFast can also remove the
// %MetadataPath%/People/<Letter>/<Name> directories the duplicate stubs left behind.
var itemsToDelete = idsToDelete
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
if (itemsToDelete.Count > 0)
// Delete in batches so we never issue one massive delete transaction and progress stays visible.
_logger.LogInformation("Deleting {Count} duplicate Person BaseItems...", idsToDelete.Count);
const int deleteBatchSize = 500;
var deletedSoFar = 0;
for (var offset = 0; offset < idsToDelete.Count; offset += deleteBatchSize)
{
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
}
cancellationToken.ThrowIfCancellationRequested();
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = idsToDelete.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
{
_persistenceService.DeleteItem(unresolvedIds);
}
var batchIds = idsToDelete.GetRange(offset, Math.Min(deleteBatchSize, idsToDelete.Count - offset));
_logger.LogInformation("Removed {Count} duplicate Person BaseItems.", idsToDelete.Count);
var itemsToDelete = batchIds
.Select(id => _libraryManager.GetItemById(id))
.Where(item => item is not null)
.ToList();
if (itemsToDelete.Count > 0)
{
_libraryManager.DeleteItemsUnsafeFast(itemsToDelete!);
}
var deletedIds = itemsToDelete.Select(i => i!.Id).ToHashSet();
var unresolvedIds = batchIds.Where(id => !deletedIds.Contains(id)).ToList();
if (unresolvedIds.Count > 0)
{
_persistenceService.DeleteItem(unresolvedIds);
}
deletedSoFar += batchIds.Count;
_logger.LogInformation("Deleting duplicate Person BaseItems: {Deleted}/{Total}", deletedSoFar, idsToDelete.Count);
}
}
private async Task MergePeoplesRowsAsync(JellyfinDbContext context, CancellationToken cancellationToken)

View File

@@ -133,10 +133,12 @@ namespace Jellyfin.Server
}
}
SetupServer.ReportActivity(StartupActivity.CheckingStorage);
StorageHelper.TestCommonPathsForStorageCapacity(appPaths, StartupLogger.Logger.With(_loggerFactory.CreateLogger<Startup>()).BeginGroup($"Storage Check"));
StartupHelpers.PerformStaticInitialization();
SetupServer.ReportActivity(StartupActivity.Initializing);
await ApplyStartupMigrationAsync(appPaths, startupConfig, options).ConfigureAwait(false);
do
@@ -195,6 +197,7 @@ namespace Jellyfin.Server
if (!string.IsNullOrWhiteSpace(_restoreFromBackup))
{
SetupServer.ReportActivity(StartupActivity.RestoringBackup);
await appHost.ServiceProvider.GetService<IBackupService>()!.RestoreBackupAsync(_restoreFromBackup).ConfigureAwait(false);
_restoreFromBackup = null;
_restartOnShutdown = true;
@@ -202,9 +205,13 @@ namespace Jellyfin.Server
}
var jellyfinMigrationService = ActivatorUtilities.CreateInstance<JellyfinMigrationService>(appHost.ServiceProvider);
SetupServer.ReportActivity(StartupActivity.PreparingMigrations);
await jellyfinMigrationService.PrepareSystemForMigration(_logger).ConfigureAwait(false);
// "Preparing migrations" carries through the DB read; per-migration progress is reported
// as "Running migration X of Y" from inside the step once the pending set is known.
await jellyfinMigrationService.MigrateStepAsync(JellyfinMigrationStageTypes.CoreInitialisation, appHost.ServiceProvider).ConfigureAwait(false);
SetupServer.ReportActivity(StartupActivity.InitializingServices);
await appHost.InitializeServices(startupConfig).ConfigureAwait(false);
_appHost = appHost;

View File

@@ -14,7 +14,6 @@ using Jellyfin.Server.Extensions;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.System;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
@@ -25,9 +24,6 @@ using Microsoft.Extensions.Diagnostics.HealthChecks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Morestachio;
using Morestachio.Framework.IO.SingleStream;
using Morestachio.Rendering;
using Serilog;
using ILogger = Microsoft.Extensions.Logging.ILogger;
@@ -44,7 +40,8 @@ public sealed class SetupServer : IDisposable
private readonly ILoggerFactory _loggerFactory;
private readonly IConfiguration _startupConfiguration;
private readonly ServerConfigurationManager _configurationManager;
private IRenderer? _startupUiRenderer;
private static volatile string _currentActivity = StartupActivity.Starting;
private StartupUiRenderer? _startupUiRenderer;
private IHost? _startupServer;
private bool _disposed;
private bool _isUnhealthy;
@@ -76,6 +73,12 @@ public sealed class SetupServer : IDisposable
internal static ConcurrentQueue<StartupLogTopic>? LogQueue { get; set; } = new();
/// <summary>
/// Gets a generic, non-identifying summary of what startup is currently doing. This is shown in the
/// always-visible header of the startup UI to unauthenticated clients, so it never contains server specific details.
/// </summary>
internal static string CurrentActivity => _currentActivity;
/// <summary>
/// Gets a value indicating whether Startup server is currently running.
/// </summary>
@@ -87,64 +90,9 @@ public sealed class SetupServer : IDisposable
/// <returns>A Task.</returns>
public async Task RunAsync()
{
var fileTemplate = await File.ReadAllTextAsync(Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
_startupUiRenderer = (await ParserOptionsBuilder.New()
.WithTemplate(fileTemplate)
.WithFormatter(
(Version version, int arg) =>
{
// version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually.
return version.ToString(arg);
},
"ToString")
.WithFormatter(
(StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
{
if (children.Any())
{
var maxLevel = logEntry.LogLevel;
var stack = new Stack<StartupLogTopic>(children);
while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
{
maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
foreach (var child in logEntry.Children)
{
stack.Push(child);
}
}
return maxLevel;
}
return logEntry.LogLevel;
},
"FormatLogLevel")
.WithFormatter(
(LogLevel logLevel) =>
{
switch (logLevel)
{
case LogLevel.Trace:
case LogLevel.Debug:
case LogLevel.None:
return "success";
case LogLevel.Information:
return "info";
case LogLevel.Warning:
return "warn";
case LogLevel.Error:
return "danger";
case LogLevel.Critical:
return "danger-strong";
}
return string.Empty;
},
"ToString")
.BuildAndParseAsync()
.ConfigureAwait(false))
.CreateCompiledRenderer();
ReportActivity(StartupActivity.Starting);
_startupUiRenderer = await StartupUiRenderer.CreateAsync(
Path.Combine(AppContext.BaseDirectory, "ServerSetupApp", "index.mstemplate.html")).ConfigureAwait(false);
ThrowIfDisposed();
var retryAfterValue = TimeSpan.FromSeconds(5);
@@ -257,13 +205,14 @@ public sealed class SetupServer : IDisposable
new Dictionary<string, object>()
{
{ "isInReportingMode", _isUnhealthy },
{ "currentActivity", CurrentActivity },
{ "retryValue", retryAfterValue },
{ "version", version },
{ "logs", startupLogEntries },
{ "networkManagerReady", networkManager is not null },
{ "localNetworkRequest", networkManager is not null && context.Connection.RemoteIpAddress is not null && networkManager.IsInLocalNetwork(context.Connection.RemoteIpAddress) }
},
new ByteCounterStream(context.Response.BodyWriter.AsStream(), IODefaults.FileStreamBufferSize, true, _startupUiRenderer.ParserOptions))
context.Response.BodyWriter.AsStream())
.ConfigureAwait(false);
});
});
@@ -309,6 +258,16 @@ public sealed class SetupServer : IDisposable
ObjectDisposedException.ThrowIf(_disposed, this);
}
/// <summary>
/// Reports the current startup activity shown to all clients in the startup UI header.
/// Only pass generic, non-identifying text from <see cref="StartupActivity"/>.
/// </summary>
/// <param name="activity">A generic description such as <see cref="StartupActivity.PreparingMigrations"/>.</param>
internal static void ReportActivity(string activity)
{
_currentActivity = activity;
}
internal void SoftStop()
{
_isUnhealthy = true;

View File

@@ -0,0 +1,41 @@
using System.Globalization;
namespace Jellyfin.Server.ServerSetupApp;
/// <summary>
/// A curated vocabulary of generic, non-identifying descriptions of what the server is doing during startup.
/// These are shown in the always-visible header of the startup UI to <b>unauthenticated</b> clients, so every
/// value must stay generic and must never contain server specific details (paths, names, plugin or migration ids, counts of items, etc.).
/// </summary>
public static class StartupActivity
{
/// <summary>The default state before any work has been reported.</summary>
public const string Starting = "Starting up";
/// <summary>Validating that the configured storage locations are usable.</summary>
public const string CheckingStorage = "Checking storage";
/// <summary>Bringing up the migration subsystem and running early startup checks.</summary>
public const string Initializing = "Initializing server";
/// <summary>Preparing the system for migrations (e.g. taking safety backups).</summary>
public const string PreparingMigrations = "Preparing migrations";
/// <summary>Restoring from a backup.</summary>
public const string RestoringBackup = "Restoring backup";
/// <summary>Bringing up core services and plugins.</summary>
public const string InitializingServices = "Initializing services";
/// <summary>Running the final startup tasks.</summary>
public const string FinishingStartup = "Finishing startup";
/// <summary>
/// Builds a generic "Running migration X of Y" description. Only the numeric position and total are exposed.
/// </summary>
/// <param name="current">The 1-based index of the migration currently running.</param>
/// <param name="total">The total number of migrations in this batch.</param>
/// <returns>A generic progress description.</returns>
public static string Migration(int current, int total)
=> string.Format(CultureInfo.InvariantCulture, "Running migration {0} of {1}", current, total);
}

View File

@@ -0,0 +1,109 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
using Morestachio;
using Morestachio.Framework.IO.SingleStream;
using Morestachio.Rendering;
namespace Jellyfin.Server.ServerSetupApp;
/// <summary>
/// Compiles and renders the startup UI Morestachio template.
/// Shared by the live <see cref="SetupServer"/> and the standalone startup UI preview tool so both
/// exercise the exact same template and formatters.
/// </summary>
public sealed class StartupUiRenderer
{
private readonly IRenderer _renderer;
private StartupUiRenderer(IRenderer renderer)
{
_renderer = renderer;
}
/// <summary>
/// Compiles the startup UI template located at <paramref name="templatePath"/>.
/// </summary>
/// <param name="templatePath">The full path to the <c>index.mstemplate.html</c> template.</param>
/// <returns>A ready to use <see cref="StartupUiRenderer"/>.</returns>
public static async Task<StartupUiRenderer> CreateAsync(string templatePath)
{
var fileTemplate = await File.ReadAllTextAsync(templatePath).ConfigureAwait(false);
var renderer = (await ParserOptionsBuilder.New()
.WithTemplate(fileTemplate)
.WithFormatter(
(Version version, int arg) =>
{
// version type does not for some stupid reason implement IFormattable which morestachio relies on for ToString support therefor we need to do it manually.
return version.ToString(arg);
},
"ToString")
.WithFormatter(
(StartupLogTopic logEntry, IEnumerable<StartupLogTopic> children) =>
{
if (children.Any())
{
var maxLevel = logEntry.LogLevel;
var stack = new Stack<StartupLogTopic>(children);
while (maxLevel != LogLevel.Error && stack.Count > 0 && (logEntry = stack.Pop()) is not null) // error is the highest inherted error level.
{
maxLevel = maxLevel < logEntry.LogLevel ? logEntry.LogLevel : maxLevel;
foreach (var child in logEntry.Children)
{
stack.Push(child);
}
}
return maxLevel;
}
return logEntry.LogLevel;
},
"FormatLogLevel")
.WithFormatter(
(LogLevel logLevel) =>
{
switch (logLevel)
{
case LogLevel.Trace:
case LogLevel.Debug:
case LogLevel.None:
return "success";
case LogLevel.Information:
return "info";
case LogLevel.Warning:
return "warn";
case LogLevel.Error:
return "danger";
case LogLevel.Critical:
return "danger-strong";
}
return string.Empty;
},
"ToString")
.BuildAndParseAsync()
.ConfigureAwait(false))
.CreateCompiledRenderer();
return new StartupUiRenderer(renderer);
}
/// <summary>
/// Renders the template with the provided model into the target stream.
/// </summary>
/// <param name="model">The values made available to the template.</param>
/// <param name="output">The stream the rendered HTML is written to.</param>
/// <returns>A Task.</returns>
public Task RenderAsync(IDictionary<string, object> model, Stream output)
{
return _renderer.RenderAsync(
model,
new ByteCounterStream(output, IODefaults.FileStreamBufferSize, true, _renderer.ParserOptions));
}
}

File diff suppressed because one or more lines are too long

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>

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