Compare commits

..

242 Commits

Author SHA1 Message Date
Cody Robibero 6d501ba418 Merge pull request #17570 from Shadowghost/fix-ef-drift
Fix EF core designer drifts
2026-08-10 19:13:29 -04:00
Cody Robibero 2a8d168796 Merge pull request #17579 from Shadowghost/fix-removal-notification
Fix missing ItemRemoved events and search fallback after access filtering
2026-08-10 18:28:22 -04:00
Cody Robibero 34cc90f5e2 Merge pull request #17599 from Shadowghost/unbreak-master
Fix master build
2026-08-10 17:29:12 -04:00
Shadowghost 26fbd1eac0 Fix master build 2026-08-10 22:46:31 +02:00
Cody Robibero 35e86416af Merge pull request #17576 from obiwantoby/perf/batch-mediasourcecount-dto
Bugfix: #17547 | Batching MediaSourceCount into one call
2026-08-10 16:15:54 -04:00
Cody Robibero c210ea87f2 Merge pull request #17597 from theguymadmax/fix-people-filtering
Fix PersonTypes not applied when filtering by person
2026-08-10 16:14:19 -04:00
Cody Robibero 4a1fcca182 Merge pull request #17582 from Shadowghost/fix-assemblies
Fix assemblies
2026-08-10 16:12:27 -04:00
Cody Robibero f5211a6722 Merge pull request #17590 from TOomaAh/fix/limit-remote-fetch-similar
fix: bound remote provider pagination
2026-08-10 16:12:11 -04:00
Cody Robibero 0b77a520fa Merge pull request #17588 from TOomaAh/fix/is-airing-filter
fix: correct IsAiring negation to exclude airing items
2026-08-10 16:11:30 -04:00
Cody Robibero d0e0e291f2 Merge pull request #17569 from vavallee/fix/17056-no-image-upscaling
Stop image endpoints from upscaling beyond the source resolution
2026-08-10 16:10:17 -04:00
Cody Robibero 82ecfde2f7 Update MediaBrowser.Controller/Drawing/ImageHelper.cs
Co-authored-by: Tim Eisele <Tim_Eisele@web.de>
2026-08-10 16:10:08 -04:00
Cody Robibero 7be16e9f74 Merge pull request #17563 from Shadowghost/cleanup-sql-helpers
Cleanup and simplify query helpers
2026-08-10 16:10:00 -04:00
theguymadmax f441c5f125 Fix PersonTypes not applied when filtering by person 2026-08-10 11:03:14 -04:00
hoanghuy309 e6e3099da9 Translated using Weblate (Vietnamese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/vi/
2026-08-10 10:46:24 +00:00
Kiki d7dd98858e Translated using Weblate (Portuguese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/pt/
2026-08-10 10:46:24 +00:00
Dan Tsivinsky 3d3d3c26b8 Translated using Weblate (Russian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ru/
2026-08-10 10:46:23 +00:00
TOomaAh 5f3e938d09 fix: bound remote provider pagination 2026-08-09 17:18:54 +02:00
TOomaAh 3031aa91ba fix: correct IsAiring negation to exclude airing items 2026-08-09 16:28:20 +02:00
Vitalijus fb763c47bf Translated using Weblate (Lithuanian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lt/
2026-08-08 21:55:48 +00:00
Doougfoks a65b614449 Translated using Weblate (Portuguese (Brazil))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/pt_BR/
2026-08-08 20:28:52 +00:00
brandon 505a6ebf8f Use WhereOneOrMany helper for the alternate version id filter
Filter the parent ids with the WhereOneOrMany query helper instead of a
raw Contains so the id list is wrapped in EF.Parameter and EF Core reuses
one compiled query plan across calls, matching how the rest of the item
queries build their id filters.
2026-08-08 15:20:39 -04:00
brandon 10d108a1f4 Address review on MediaSourceCount batching
Rename GetItemsWithAlternateVersions to GetItemIdsWithAlternateVersions
across the interfaces and implementations since it returns ids. Return
the hashset straight from the query instead of materializing an array
first. Rename the DtoService guard to mayHaveAlternateVersions and
invert it so the computed path is the explicit case. Assert the media
source count value in the batch skip test and add a test covering an
item that is in the returned set still resolving to the correct count.
2026-08-08 12:33:11 -04:00
Shadowghost 428c0f19bc Fix bump_version script 2026-08-08 18:26:46 +02:00
Shadowghost afd91a3d9d Fix assemblies 2026-08-08 18:20:19 +02:00
queeup a0b291a463 Translated using Weblate (Turkish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/tr/
2026-08-08 16:14:51 +00:00
Thadah D. Denyse a9e4c826aa Translated using Weblate (Basque)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/eu/
2026-08-08 14:03:01 +00:00
hoanghuy309 b189813eb1 Translated using Weblate (Vietnamese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/vi/
2026-08-08 14:02:57 +00:00
queeup 7c20ea2708 Translated using Weblate (Turkish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/tr/
2026-08-08 14:02:53 +00:00
Shadowghost e63c05137a Fix missing ItemRemoved events and search fallback after access filtering 2026-08-08 10:59:37 +02:00
brandon c091ffdc6b Batch alternate version detection in DtoService to remove MediaSourceCount N+1
Browsing a page of videos with the MediaSourceCount field ran one alternate
version query per item, each opening a fresh DbContext. On a large library that
turned a single page into hundreds of sequential round trips and made the Items
endpoint take tens of seconds while holding a request thread the whole time.

Detect which videos own alternate versions once per page with a single query,
mirroring the existing people batch. Videos absent from that set have a single
media source, so the per item lookups are skipped for the common case. Behavior
is unchanged: a video with no alternates already resolved to a count of one.

Adds a regression test asserting the count resolves from the batch and the per
item lookups are never called.
2026-08-07 22:51:45 -04:00
Cody Robibero 70ffd25b50 Merge pull request #17560 from IDisposable/chore/pessimistic-reality-check
Add warning that PessimisticLockBehavior is unsafe
2026-08-07 21:43:40 -04:00
Cody Robibero 247ee406a9 Merge pull request #17571 from obiwantoby/perf/batch-people-dto
Batch people lookups when building item DTOs
2026-08-07 21:43:21 -04:00
Cody Robibero 2218f2931c Merge pull request #17492 from GOvEy1nw/fix/image-cache-overlay-key
fix(images): disambiguate progress overlay cache keys
2026-08-07 21:42:16 -04:00
Cody Robibero 6bc1c18004 Merge pull request #17541 from vdatanet/fix/byname-total-record-count
Fix by-name endpoints reporting TotalRecordCount=0 next to a populated Items array
2026-08-07 21:40:58 -04:00
Cody Robibero eee818094e Merge pull request #17525 from rlauuzo/master
Clear metadata provider cache when provider parts are registered
2026-08-07 21:40:22 -04:00
Cody Robibero 0ec7cc5f5b Merge pull request #17555 from IDisposable/fix/reorder-update-items
Delete old related info in bulk as late as possible in UpdateOrInsertItems
2026-08-07 21:40:11 -04:00
Cody Robibero 871970120d Merge pull request #17521 from Shadowghost/fix-plugin-disable
Fix disabled plugins being re-enabled on restart
2026-08-07 21:39:39 -04:00
Cody Robibero 59bef7940b Merge pull request #17559 from IDisposable/fix/optimistic-lock-behavior
Fix captured (and discarded) Execute exceptions
2026-08-07 21:39:30 -04:00
Cody Robibero 830d93e446 Merge pull request #17554 from IDisposable/fix/faster-update-items
Speed up UpdateOrInsertItems for related information
2026-08-07 21:39:10 -04:00
Cody Robibero d696cea9bc Merge pull request #17558 from IDisposable/fix/set-cache-to-private
Switch SQLite connection Cache to Private
2026-08-07 21:38:59 -04:00
queeup e83125c17c Translated using Weblate (Turkish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/tr/
2026-08-08 01:38:51 +00:00
Vitalijus c5da7db120 Translated using Weblate (Lithuanian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lt/
2026-08-08 01:38:51 +00:00
Cody Robibero f9b7f2edf7 Merge pull request #17536 from gnattu/fix-concurrent-racing
Fix concurrent ffmpeg segment racing
2026-08-07 21:38:45 -04:00
Vitalijus 4192a4dd32 Translated using Weblate (Lithuanian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lt/
2026-08-07 19:43:35 +00:00
Vitalijus 84ffe668d0 Translated using Weblate (Lithuanian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lt/
2026-08-07 19:25:33 +00:00
brandon d6da6906a4 Batch people lookups when building item DTOs
GetBaseItemDtos already batch fetches user data, child counts, played counts
and artists before its per item loop, but AttachPeople still ran one GetPeople
query per item. Rendering a page of items (for example a large playlist) fired
one extra query per row.

Add GetPeopleByItems to IPeopleRepository, which reads every requested item in a
single query over the people mapping table and returns full PersonInfo (role,
type and sort order) grouped by item id. GetBaseItemDtos prefetches this once
when the People field is requested and passes it into AttachPeople, which reads
from the batch instead of querying per item. The single item GetBaseItemDto path
keeps its existing per item behaviour when no batch is supplied.

Adds a DtoService test asserting people resolve from the batch and the per item
GetPeople is never called.
2026-08-07 11:59:17 -04:00
Shadowghost b7e2d21038 Fix EF core designer drifts 2026-08-07 17:51:14 +02:00
vavallee e120b7f2dd Stop image endpoints from upscaling beyond the source resolution
ImageHelper.GetNewImageSize passed the caller-supplied width/height straight
through to SkiaEncoder.EncodeImage, which allocates an SKImageInfo of exactly
that size. Nothing bounded those values against the source image, so a request
like Items/<id>/Images/Primary?width=23100&height=23100 made the server allocate
and resample a 23100x23100 surface from, say, a 600x336 poster: the reporter
measured 100% of a core for 10-15 minutes and 6-12 GB resident per request.
The item images endpoints do not require authentication, so any caller who knows
an item id can trigger this, and varying the size by one pixel misses the cache
every time.

Add DrawingUtils.ScaleDownToFit, which scales a size down uniformly until it
fits inside a bounding box and returns it unchanged if it already does, and
apply it in GetNewImageSize against the original image dimensions. Requests
that ask for more pixels than the source now get the source resolution back,
scaled to the requested aspect ratio. Downscaling paths are untouched, and
DrawingUtils.Resize keeps its existing behaviour for the transcoding callers in
EncodingJobInfo and StreamInfo, which legitimately size video output.
ResizeFill already refused to upscale; this makes width/height consistent
with fillWidth/fillHeight.

Fixes #17056.
2026-08-07 12:33:37 -03:00
Vitalijus 6c37a6ef8b Translated using Weblate (Lithuanian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lt/
2026-08-07 11:11:10 +00:00
Shadowghost 9162c17834 Apply review suggestions 2026-08-07 07:23:50 +02:00
Vitalijus 929834c982 Translated using Weblate (Lithuanian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lt/
2026-08-06 19:11:07 +00:00
Shadowghost 5b3da3bcd7 Cleanup and simplify query helpers 2026-08-06 12:42:53 +02:00
Keleti Márton 4860f945a7 Translated using Weblate (Hungarian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/hu/
2026-08-06 09:43:17 +00:00
simonanter 9f74fd7354 Translated using Weblate (German)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/de/
2026-08-06 09:43:16 +00:00
Marc Brooks 1c5c95ad1d Move the deletion of old related info to just before the save
This makes the deletion of BaseItemProviders, BaseItemImageInfos, and BaseItemMetadataFields happen in batch as  a contiguous block so the lock isn't held across items, just before the bulk SaveChanges.
2026-08-06 01:00:17 -05:00
Marc Brooks dd81e52f81 Add warning that PessimisticLockBehavior is unsafe
Since it uses ReaderWriterLockSlim, it gets tripped up if the transaction
is continued across an await boundary on a different thread.
2026-08-06 00:52:34 -05:00
Marc Brooks 868ad089f8 Fix captured (and discarded) Execute exceptions
Using ExecuteAndCapture (and the async version) requires something to manage the captured exception, which we don't do. Errors would be silently dropped and the writes/deletes treated as if they succeeded.
2026-08-06 00:44:51 -05:00
Marc Brooks 2a08a30982 Switch SQLite connection Cache to Private
If left at Default,  sqlite3_enable_shared_cache is process-global, so a plugin enabling it makes these connections share a cache too.
Contention then surfaces as SQLITE_LOCKED ("database table is locked"), which the busy handler does not cover,  busy_timeout is skipped and the command fails at CommandTimeout instead.
2026-08-06 00:36:01 -05:00
Marc Brooks 812c819162 Speed up search for existing item values
Replace the O(n²)  array .First scan with a dictionary lookup
2026-08-05 22:48:26 -05:00
Marc Brooks 234a49903b Use a HashSet for existing items
Makes the test O(log n)
2026-08-05 22:48:13 -05:00
Cody Robibero cd5962df50 Merge pull request #17315 from Florin-Popescu/replaygain-missing-tags
Parse ReplayGain album gain field
2026-08-05 19:04:05 -04:00
krvi 9d99164c19 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-08-05 22:41:37 +00:00
Kityn 2db10787af Translated using Weblate (Polish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/pl/
2026-08-05 22:41:34 +00:00
Luis Fernando Illapa 614e5c7912 Translated using Weblate (Spanish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/es/
2026-08-05 22:41:27 +00:00
Cody Robibero 17b0453cfb Merge pull request #17466 from Shadowghost/fix-byname-queries
Improve People deduplication, fix search and restrict ItemByName responses
2026-08-05 18:39:55 -04:00
Cody Robibero d5a5b56484 Merge pull request #17537 from vdatanet/fix/pcm-wav-transcode
Fix PCM audio transcoding to wav returning HTTP 500 and headerless output
2026-08-05 18:39:42 -04:00
Cody Robibero ef1ce3d6eb Merge pull request #17549 from theguymadmax/revert-livetv-channel-icon-refresh
Revert "Refresh Live TV channel icons on every guide update."
2026-08-05 18:38:51 -04:00
Cody Robibero 07fb350333 Merge pull request #17524 from Shadowghost/fix-extra-discovery
Keep folder extras with the item that owns the folder
2026-08-05 18:38:31 -04:00
theguymadmax f49a501f71 Revert "Refresh Live TV channel icons on every guide update."
This reverts commit 372c1681d8.
2026-08-05 14:02:28 -04:00
Bond-009 f6ca06e9bf Merge pull request #17507 from jellyfin/renovate/sharpcompress-0.x
Update dependency SharpCompress to 0.50.4
2026-08-05 18:07:52 +02:00
renovate[bot] ef84408199 Update dependency SharpCompress to 0.50.4 2026-08-05 15:55:11 +00:00
Luis Fernando Illapa 0db3759325 Translated using Weblate (Spanish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/es/
2026-08-05 15:54:13 +00:00
rimasx df1611cf95 Translated using Weblate (Estonian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/et/
2026-08-05 15:54:13 +00:00
Dan Johansen a6d09815f2 Translated using Weblate (Danish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/da/
2026-08-05 15:54:13 +00:00
Helak 38aa870514 Translated using Weblate (Czech)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/cs/
2026-08-05 15:54:13 +00:00
Bond-009 d13c65229d Merge pull request #17493 from jellyfin/renovate/ci-deps
Update github/codeql-action action to v4.37.6
2026-08-05 17:54:07 +02:00
Shadowghost 61e75599b3 Project the lowered person credit values once when updating people 2026-08-05 13:21:57 +02:00
Shadowghost b2485af6e9 Merge remote-tracking branch 'upstream/master' into fix-byname-queries
# Conflicts:
#	Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
2026-08-05 12:15:30 +02:00
vdatanet 4adaf7f146 Fix by-name endpoints reporting TotalRecordCount=0 next to a populated Items array
`GetItemValues` -- the shared path behind `/Artists`, `/AlbumArtists`, `/Genres`,
`/MusicGenres` and `/Studios` -- disabled the total record count whenever the
query carried no `Limit`:

    if (!filter.Limit.HasValue)
    {
        filter.EnableTotalRecordCount = false;
    }

A request without an explicit limit therefore came back with N entries in `Items`
and `TotalRecordCount = 0`. Clients that page on the reported total -- the
documented contract every other list endpoint honours -- read that as an empty
library. `/Items` and `/Persons` do not share this path and report the count
correctly, which is what makes the inconsistency visible from the outside.

Measured against master with a 62-track music library:

    GET /Artists?UserId=...              -> TotalRecordCount=0  Items=5
    GET /Artists?UserId=...&limit=100    -> TotalRecordCount=5  Items=5

Dropping the block costs nothing: `representativeIds` is materialised into a
`List<Guid>` a few lines below regardless, so `.Count` was already available and
the count is now reported from it. Callers that genuinely want to skip the count
still can -- `EnableTotalRecordCount = false` is honoured as before.

The block also mutated the caller's own query object, so a query instance reused
across calls silently lost its total after the first limitless one. That is
covered by a test as well.
2026-08-05 11:34:33 +02:00
krvi 563a00faaa Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-08-05 01:46:45 +00:00
krvi 5e33a2d376 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-08-04 22:40:18 +00:00
Cody Robibero d9d9efc96e Merge pull request #17523 from Shadowghost/fix-resume-container-folders
Only treat series and seasons as resumable folders
2026-08-04 18:01:13 -04:00
Vitalijus dd1380e562 Translated using Weblate (Lithuanian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lt/
2026-08-04 21:58:15 +00:00
Translation expert cfb00b58a2 Translated using Weblate (Arabic)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ar/
2026-08-04 21:58:15 +00:00
Cody Robibero 990ab18353 Merge pull request #17528 from Shadowghost/websocket-logs-debug
Degrade ForceKeepAlive logs to debug
2026-08-04 17:58:09 -04:00
vdatanet 3578e9a332 Fix PCM audio transcoding to wav returning HTTP 500 and headerless output
`GetProgressiveAudioFullCommandLine` forced the raw PCM muxer and a bogus
sample rate whenever the audio encoder was `pcm_*`, regardless of the
container the client asked for. Two separate failures came out of it:

- `-ar ` + `state.BaseRequest.AudioBitRate` used a *bitrate* as a *sample
  rate*, and `AudioBitRate` is optional. When it is absent the argument
  degrades to a bare `-ar`, ffmpeg aborts with `Expected number for ar but
  found: -ar` / `Error opening output files: Invalid argument` (exit 234)
  and the request fails with HTTP 500. Every `GET /Audio/{id}/stream.wav`
  that does not carry an explicit `AudioBitRate` hits this.
  The sample rate was already being set correctly a few lines below from
  `OutputAudioSampleRate`, so the line is dropped rather than repaired.

- `-f s16le` overrode the muxer even for a real container. A request that
  did supply a bitrate (`/Audio/{id}/universal` passes
  `MaxStreamingBitrate`) survived the first bug but produced raw headerless
  samples served under an `audio/wav` content type, so clients saw a body
  with no RIFF header. The raw muxer is now only forced when the requested
  container is actually raw PCM, which keeps the I2S/MCU route from #10321
  working.

Also drop the `containerInternal = ".pcm"` assignment in
`StreamingHelpers.GetStreamingState`: it is written after
`state.OutputContainer` has already been read from the same variable and is
never read again, so it has no effect and only obscures where the output
container comes from.

Verified against ffmpeg 8.1.2 with a 96 kHz FLAC source: before, the wav
command line exits 234; after, it produces a valid `RIFF/WAVE` 48 kHz stereo
`pcm_s16le` file, while the raw `.pcm` route still yields exactly
2 s x 48000 x 2ch x 2 B = 384000 bytes of headerless samples.
2026-08-04 19:52:29 +02:00
renovate[bot] f60cfc788b Update github/codeql-action action to v4.37.6 2026-08-04 16:41:10 +00:00
gnattu e2586eed9b Fix concurrent ffmpeg segment racing
This is a nasty one. The failure mode is:

1. Request A started FFmpeg and waited for a segment.
2. Request B requested an earlier or far away segment.
3. Jellyfin thought FFmpeg should to restart at a different position.
4. Request B killed the existing transcoding job.
5. Killing that job cancelled the same token request A was using.
6. The cancellation produced http 500 to request A.

To fix this:

we lock transcoding job state changes and segment handling per playlist, and use a thread safe counter to track how many http responses are still using each job’s segments. A job is only stopped or replaced once that counter reaches zero.
2026-08-05 00:33:42 +08:00
Shed Shedson 7fbc1ff8c0 Translated using Weblate (Icelandic)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/is/
2026-08-04 11:29:56 +00:00
Yunseo Jung 5767a3a800 Translated using Weblate (Korean)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ko/
2026-08-04 11:29:55 +00:00
Vincenzo Reale 4b51ef2c2f Translated using Weblate (Italian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/it/
2026-08-04 11:29:55 +00:00
krvi f50fc0fa86 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-08-03 23:01:06 +00:00
potuzhnyj a817391f67 Translated using Weblate (Ukrainian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/uk/
2026-08-03 23:01:06 +00:00
Erik W cc877229b1 Translated using Weblate (Swedish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/sv/
2026-08-03 23:01:05 +00:00
Vitalijus a3e7024355 Translated using Weblate (Lithuanian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lt/
2026-08-03 23:01:04 +00:00
Shadowghost d3c7aa6bd2 Fix /Persons favourites timing out by removing the correlated favourites join 2026-08-04 00:40:53 +02:00
Shadowghost 40601523e7 Degrade ForceKeepAlive logs to debug 2026-08-03 21:30:22 +02:00
krvi e3664a9561 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-08-03 18:59:02 +00:00
nextlooper42 a1ed4b9001 Translated using Weblate (Slovak)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/sk/
2026-08-03 18:59:02 +00:00
Vincenzo Reale e09492de62 Translated using Weblate (Italian)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/it/
2026-08-03 18:59:01 +00:00
myrad2267 d10381a0e2 Translated using Weblate (French)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fr/
2026-08-03 18:59:01 +00:00
myrad2267 9596115c45 Translated using Weblate (French (Canada))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fr_CA/
2026-08-03 18:59:00 +00:00
Translation expert ed0be243e9 Translated using Weblate (Arabic)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ar/
2026-08-03 18:58:59 +00:00
Shadowghost f502fd312e Fix test 2026-08-03 15:29:32 +02:00
rlauuzo 9d0dffe818 Clear metadata provider cache when provider parts are registered
AddParts replaces _metadataProviders but left _metadataProviderCache
holding provider arrays from the previous registration. Invalidate the
cache so repeated AddParts calls cannot serve stale providers, and give
the previously uncalled ClearMetadataProviderCache its intended caller.
2026-08-03 11:56:08 +02:00
Shadowghost 4e2089b6a1 Keep folder extras with the item that owns the folder 2026-08-03 10:50:33 +02:00
Shadowghost 55518c06ee Only treat series and seasons as resumable folders 2026-08-03 09:01:55 +02:00
Shadowghost a272efb9a2 Fix disabled plugins being re-enabled on restart 2026-08-03 08:48:09 +02:00
Robin 33a8cdfc0b Translated using Weblate (Spanish (Latin America))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/es_419/
2026-08-03 06:20:33 +00:00
Robin 38c6c5b478 Translated using Weblate (Spanish (Mexico))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/es_MX/
2026-08-03 06:20:32 +00:00
Robin d759df03b5 Translated using Weblate (Spanish (Argentina))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/es_AR/
2026-08-03 06:20:31 +00:00
Kaz e2c1121079 Translated using Weblate (German)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/de/
2026-08-03 06:20:29 +00:00
無情天 fe3585577b Translated using Weblate (Chinese (Simplified Han script))
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/zh_Hans/
2026-08-03 01:53:49 +00:00
Bas b3a0611302 Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/nl/
2026-08-02 20:18:33 +00:00
Gargotaire c20cc766e1 Translated using Weblate (Catalan)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ca/
2026-08-02 20:18:33 +00:00
Shadowghost 8044534774 Merge remote-tracking branch 'upstream/master' into fix-byname-queries 2026-08-02 22:12:57 +02:00
Bas cac463d4c3 Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/nl/
2026-08-02 18:49:57 +00:00
Bas 7faa35dab4 Translated using Weblate (Dutch)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/nl/
2026-08-02 18:49:28 +00:00
Cody Robibero 24022092ad Merge pull request #17456 from Shadowghost/fix-extras
Fix extras naming and version assignment
2026-08-02 14:25:00 -04:00
Cody Robibero e2e3094d2d Merge pull request #17512 from altqx/libbitsub
Allow client-rendered graphical subtitles during remux
2026-08-02 14:24:51 -04:00
Cody Robibero d669994bf0 Merge pull request #17503 from theguymadmax/fix-sortby-name
Use CleanName when sorting by name
2026-08-02 14:24:28 -04:00
Cody Robibero 45b8f6c4c8 Merge pull request #17486 from Shadowghost/fix-adjacent
Fix AdjacentTo being ignored on non-recursive item queries
2026-08-02 14:24:13 -04:00
Cody Robibero 044f651299 Merge pull request #17482 from Shadowghost/fix-stale-versions
Fix video version links being read from stale serialised item data instead of the LinkedChildren table
2026-08-02 14:24:05 -04:00
Cody Robibero f865910a90 Merge pull request #17298 from WizardOfYendor1/fix/livetv-published-stream-urls
Fix Live TV returning unreachable "server-local" streaming URLs to clients.
2026-08-02 14:23:49 -04:00
Cody Robibero f2360c07a3 Merge pull request #17147 from Shadowghost/tmdb-missing-episodes
Add Tmdb missing episode provider
2026-08-02 14:23:30 -04:00
Shadowghost 4c812c9ba4 Switch to opt-in 2026-08-02 18:02:57 +02:00
krvi 26261dbfe7 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-08-02 01:08:15 +00:00
krvi ae90e0e52e Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-08-01 22:58:15 +00:00
krvi 72d0895401 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-08-01 22:10:09 +00:00
Shadowghost 705368ee49 Merge remote-tracking branch 'upstream/master' into fix-byname-queries
# Conflicts:
#	src/Jellyfin.Database/Jellyfin.Database.Providers.Sqlite/Migrations/JellyfinDbModelSnapshot.cs
2026-08-01 21:41:16 +02:00
Shadowghost cbc2c7c323 Preserve multiple roles per person type instead of deduping credits by name and type 2026-08-01 20:35:27 +02:00
Weblate e16c8a07bd Update translation files
Updated by "Remove blank strings" hook in Weblate.

Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/
2026-08-01 18:21:52 +00:00
krvi a7a138db31 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-08-01 18:21:48 +00:00
krvi 76956c0abc Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-08-01 17:49:28 +00:00
altqx d4376e0539 Allow client-rendered graphical subtitles during remux 2026-08-01 22:49:34 +07:00
Shadowghost b94ba9d409 Merge remote-tracking branch 'upstream/master' into tmdb-missing-episodes
# Conflicts:
#	Jellyfin.Server.Implementations/Item/BaseItemRepository.QueryBuilding.cs
2026-08-01 14:25:20 +02:00
Cody Robibero 744ca84a8b Merge pull request #17500 from theguymadmax/fix-storage-lookup
Fix storage info lookup for Windows
2026-08-01 08:18:45 -04:00
Cody Robibero c55fde25a5 Merge pull request #17501 from alchemyyy/fix/skip-sidx-fmp4-hls
Skip SIDX in fMP4 HLS segments
2026-08-01 08:07:42 -04:00
Cody Robibero 030031dcff Merge pull request #17463 from Shadowghost/fix-unplayed-filter
Fix (Un)Played filter correctness and performance
2026-08-01 08:07:05 -04:00
Cody Robibero 341c19bace Merge pull request #17460 from Shadowghost/fix-user-items
Queue person metadata refresh instead of blocking the item request and fix ItemCounts
2026-08-01 08:06:46 -04:00
Cody Robibero e3a8d209b4 Merge pull request #17455 from Shadowghost/fix-migrations
Reevaluate pending migrations after each one instead of per stage
2026-08-01 08:06:33 -04:00
Cody Robibero e816870f67 Merge pull request #17416 from Shadowghost/enable-duplicate-playlist-children
Allow duplicate LinkedChildren for Playlists
2026-08-01 08:06:02 -04:00
theguymadmax f28acc7fa1 Use CleanName when sorting by name 2026-07-31 00:34:43 -04:00
krvi b2ae39e0d9 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-07-31 02:19:10 +00:00
alchemyyy 893662ba50 Skip SIDX in fMP4 HLS segments
Prevent FFmpeg's SIDX path from rewriting open-GOP boundary packet timestamps. HLS uses the media playlist for segment indexing and does not require the SIDX box.
2026-07-30 17:19:45 -07:00
theguymadmax 314004bc19 Fix storage lookup for Windows 2026-07-30 19:13:32 -04:00
GOvEy1nw 0915a61c19 fix(images): narrow cache path test seam 2026-07-30 09:20:01 +01:00
Shadowghost e123a13e38 Apply the by-name access exemption in the search candidate query 2026-07-30 09:53:44 +02:00
krvi b15d3c9ad3 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-07-30 02:32:30 +00:00
GOvEy1nw 3da05330b2 fix(images): disambiguate progress overlay cache keys 2026-07-29 17:37:36 +01:00
Shadowghost 8293eb26b9 Fix playlist entries being lost on migration and library scans 2026-07-29 14:40:49 +02:00
Shadowghost d8fc0a9914 Fix AdjacentTo being ignored on non-recursive item queries 2026-07-29 13:13:44 +02:00
Shadowghost 1f4f4acb46 Fixup 2026-07-29 09:39:04 +02:00
Shadowghost d64e18b69a Fix more filter cases 2026-07-29 09:35:20 +02:00
Shadowghost 7a4271c85f Fix video version links being read from stale serialised item data instead of the LinkedChildren table 2026-07-29 07:31:27 +02:00
Paroc afb716566d Translated using Weblate (Occitan)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/oc/
2026-07-29 00:32:44 +00:00
Cody Robibero e5c68e1257 Merge pull request #17424 from Shadowghost/add-audiodb-search
Implement AudioDb artist search
2026-07-28 19:15:20 -04:00
Shadowghost a94588497c Fix Folder access filtering 2026-07-28 23:13:10 +02:00
Shadowghost 4cedbe28e3 Apply review suggestion 2026-07-28 22:18:18 +02:00
Shadowghost 9a258c089d Restrict people, genres, studios and artists to names backed by an item the user can access 2026-07-28 21:28:56 +02:00
Bond-009 9ff92f3f0d Merge pull request #17461 from jellyfin/renovate/actions-stale-11.x
Update actions/stale action to v11
2026-07-28 20:21:00 +02:00
Shadowghost 5f5c71ab75 Make the /Persons de-duplication use an index instead of grouping the table 2026-07-28 20:18:02 +02:00
Bond-009 d92e59aa72 Merge pull request #17459 from jellyfin/renovate/ci-deps
Update danielpalme/ReportGenerator-GitHub-Action action to v5.5.11
2026-07-28 20:11:33 +02:00
Bond-009 d593436281 Merge pull request #17448 from jellyfin/renovate/fscheck.xunit.v3-3.x
Update dependency FsCheck.Xunit.v3 to 3.3.4
2026-07-28 20:10:35 +02:00
Bond-009 41b8bd4459 Merge pull request #17310 from TowyTowy/fix/format3d-trailing-token
Fix 3D format detection when the tag is the last token of the path
2026-07-28 20:05:51 +02:00
Florin Popescu f6f0fed2d1 skip album ReplayGain parsing if no album or value already present 2026-07-28 19:53:50 +02:00
Florin Popescu e2adf2e2bf added null checks 2026-07-28 19:53:50 +02:00
Florin Popescu 78b535b229 parse replaygain reference loudness & album gain fields 2026-07-28 19:53:50 +02:00
David Wagener 7215950bd4 Translated using Weblate (Luxembourgish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lb/
2026-07-28 16:27:52 +00:00
Natawan Jongnetiwisit c435f63d09 Translated using Weblate (Thai)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/th/
2026-07-28 16:27:49 +00:00
Shadowghost eed664b7d3 Fix played/unplayed filter for empty Series and BoxSets 2026-07-28 12:43:12 +02:00
David Wagener 5b550517b2 Translated using Weblate (Luxembourgish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/lb/
2026-07-28 08:36:53 +00:00
krvi e565073fd5 Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-07-28 08:36:53 +00:00
renovate[bot] 2b93191714 Update actions/stale action to v11 2026-07-28 03:41:28 +00:00
Shadowghost 046225654a Queue person metadata refresh instead of blocking the item request and fix ItemCounts 2026-07-27 23:09:30 +02:00
renovate[bot] 0db27d7b1b Update danielpalme/ReportGenerator-GitHub-Action action to v5.5.11 2026-07-27 20:08:23 +00:00
Shadowghost f571cd5a6a Reevaluate pending migrations after each one instead of per stage 2026-07-27 12:35:59 +02:00
Shadowghost 79a55327dc Fix extras naming and version assignment 2026-07-27 12:12:17 +02:00
Cody Robibero dbc796b0b0 Merge pull request #16409 from elio42/fix/create_library_thumbs_on_first_scan
Fix missing collection folder posters after initial scans.
2026-07-26 16:19:25 -04:00
Cody Robibero 02d8e7d828 Merge pull request #17417 from Shadowghost/series-merge-fixes
Fix series merging
2026-07-26 16:19:11 -04:00
Cody Robibero 1e4d126cb9 Merge pull request #17422 from Shadowghost/performance
Reduce correlated subqueries to improve query performance
2026-07-26 16:18:49 -04:00
Shadowghost f3a1d56c56 Use DistinctBy where possible 2026-07-26 09:56:49 +02:00
renovate[bot] 67b8684f3c Update dependency FsCheck.Xunit.v3 to 3.3.4 2026-07-26 00:07:59 +00:00
Shed Shedson b04614e18d Translated using Weblate (Icelandic)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/is/
2026-07-25 18:33:24 +00:00
Cody Robibero 689a90d275 Merge pull request #17443 from jellyfin/renovate/sharpcompress-0.x
Update dependency SharpCompress to 0.50.1
2026-07-25 14:05:03 -04:00
Shadowghost 766be1e8bb Fix favorite filter performance 2026-07-25 19:09:39 +02:00
Shadowghost f2f66606d7 Fix DatePlayed sorting performance 2026-07-25 19:09:39 +02:00
renovate[bot] 949fa4b513 Update dependency SharpCompress to 0.50.1 2026-07-25 16:53:47 +00:00
Cody Robibero 40fa2fd390 Merge pull request #17442 from Shadowghost/fix-numbers-in-episode-names
Fix hyphenated numbers in episode titles parsed as multi-episodes
2026-07-25 12:53:38 -04:00
Cody Robibero 7f26bd1091 Merge pull request #17399 from Shadowghost/fix-extra-year
Fix incorrect year on local trailers
2026-07-25 12:52:51 -04:00
Cody Robibero 86ac1aaa6b Merge branch 'master' into fix/create_library_thumbs_on_first_scan 2026-07-25 12:52:11 -04:00
Shadowghost 3c9727d36c Always inherit from owner item and add tests 2026-07-25 17:17:30 +02:00
Shadowghost 5a2809e337 Fix TmdbMissingEpisodeProvider 2026-07-25 16:25:48 +02:00
Shadowghost d1d89dfb12 Fix hyphenated numbers in episode titles parsed as multi-episodes 2026-07-25 16:21:26 +02:00
Cody Robibero 45ec0ed8b5 Merge pull request #17437 from dkanada/ogg-formats
remove ogg from video extensions since it should only be used for audio
2026-07-25 09:17:42 -04:00
Cody Robibero ebb66f6ca3 Merge pull request #17395 from paoloantinori/fix/userdata-null-user-nre-master
Avoid NRE when sorting by user-dependent keys without a user
2026-07-25 08:38:43 -04:00
krvi b85c9186ef Translated using Weblate (Faroese)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/fo/
2026-07-25 11:40:34 +00:00
Suyash Mittal c606102f6d Translated using Weblate (Hindi)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/hi/
2026-07-25 11:40:33 +00:00
Paolo Antinori 8b70582561 Remove added comments (#17395 review) 2026-07-25 12:50:32 +02:00
dkanada b62fef3c1f remove ogg from video extensions since it should only be used for audio 2026-07-25 14:10:30 +09:00
Cody Robibero 71ab342838 Merge pull request #17423 from Shadowghost/fix-comicinfo
Skip ComicInfo parsing if none exists
2026-07-24 21:43:34 -04:00
Cody Robibero 83c4681e99 Merge pull request #17234 from Eneo-org/fix/syncplay-playqueue-index
Fix play queue index handling in SyncPlay
2026-07-24 21:35:49 -04:00
Cody Robibero 42e52f60ad Merge pull request #17402 from Shadowghost/clean-forced-sort-name
Apply cleaning logic on ForcedSortName
2026-07-24 21:34:37 -04:00
Cody Robibero 6ac64c5319 Merge pull request #17419 from rwebster85/mp4-audio-subtitle-names
Check the "name" tag for audio/subtitle probe to fix MP4 not showing correctly - Fixes issue #17418
2026-07-24 21:30:54 -04:00
Cody Robibero 83e0cfd7ee Merge pull request #17430 from jellyfin/drain-stderr
Drain stderr and stdout concurrently for encoder validation
2026-07-24 21:29:28 -04:00
Cody Robibero 700e53a807 Merge pull request #17411 from mbastian77/perf/session-manager-tolist
perf: avoid unnecessary list allocation in CheckForIdlePlayback
2026-07-24 21:28:55 -04:00
Shadowghost 4d1f90b2f7 Don't pull MusicBrainz Annotations into overview 2026-07-24 19:38:38 +02:00
gnattu d74babd8f3 Drain stderr and stdout concurrently for encoder validation
Some ffmpeg build might output extremely long traces for its banner that consume all pipe capacity and hangs the process. We have to drain both streams regardless on which one we actually read.
2026-07-24 20:01:01 +08:00
Shadowghost b7b2700425 Fix MusicBrainz Metadata fetching 2026-07-23 23:07:58 +02:00
Shadowghost 04e4505402 Fix AudioDB metadata fetching 2026-07-23 22:54:26 +02:00
Shadowghost 95330223f4 Speedup migration 2026-07-23 22:38:45 +02:00
Shadowghost f28fa563c9 Guard against blank names 2026-07-23 22:16:47 +02:00
Shadowghost d6ce6ae8b9 Skip ComicInfo parsing if none exists 2026-07-23 22:08:04 +02:00
Shadowghost 8f57f35372 Implement AudioDb artist search 2026-07-23 21:21:28 +02:00
Shadowghost 9aa79682ba Reduce correlated subqueries to improve performance 2026-07-23 20:50:06 +02:00
Shadowghost d4cddb8a5d Fix series merging 2026-07-23 14:03:13 +02:00
Shadowghost dc300fae53 Allow duplicate LinkedChildren for Playlists 2026-07-23 13:41:51 +02:00
Richard Webster 70980f09de Update contributors 2026-07-23 11:59:43 +01:00
Richard Webster ca0cf763ff Update comment 2026-07-23 11:57:34 +01:00
Richard Webster 3d4c52092e Clarify comment about MP4 track title workaround 2026-07-23 10:13:04 +01:00
mbastian77 fc0af10509 Avoid unnecessary list allocation in CheckForIdlePlayback 2026-07-23 03:25:58 +02:00
renovate[bot] 88216e0ec4 Update github/codeql-action action to v4.37.3 (#17398)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-07-22 19:46:55 +02:00
Richard Webster 474ae50c36 Check the "name" tag, not just "title" 2026-07-22 18:00:18 +01:00
Shadowghost 929e1936eb Apply cleaning logic on ForcedSortName 2026-07-22 08:30:29 +02:00
Shadowghost 6382563440 Prefer null checks over HasValue everywhere 2026-07-22 08:09:33 +02:00
Shadowghost 733b4ba73a Prefer null checks over HasValue 2026-07-22 08:09:15 +02:00
Paolo Antinori 5d580abb08 fix: avoid NRE when sorting by user-dependent keys without a user
A query sorted by a user-dependent key (PlayCount, IsFavoriteOrLiked,
DatePlayed, IsPlayed, IsUnplayed) but carrying no User caused a
NullReferenceException inside UserDataManager.GetUserData, surfacing as
"Failed to compare two elements in the array" (InvalidOperationException
wrapping the NRE from the LINQ sort) and 500-ing the /Items request.

Root cause: LibraryManager.GetComparer assigned comparer.User = user
without a null guard, so PlayCountComparer.GetValue called
UserDataManager.GetUserData(null, item), dereferencing user.Id.

Two-part fix:
- LibraryManager.GetComparer: when user is null and the sort key requires a
  user (IUserBaseItemComparer), substitute the SortName comparer so the
  result stays deterministic instead of 500-ing. SortName is the project's
  canonical tiebreaker (ItemsController injects it for album-by-artist).
- UserDataManager.GetUserData: ArgumentNullException.ThrowIfNull(user) as
  defense in depth (matches the existing guards on the SaveUserData
  overloads in the same file). On master this overload was rewritten to use
  ResolveUserDataRow, so the NRE dereferences user.Id rather than
  user.InternalId as on the release branch — same bug, different line.

Also fixes DateLastMediaAddedComparer being statically mis-tagged as
IUserBaseItemComparer: its GetDate is static and never reads User, so it
does not need one. Without this, the SortName fallback above would wrongly
engage for DateLastContentAdded on anonymous queries (returning SortName
order instead of date order). Re-tagged to IBaseItemComparer and dropped the
unused User/UserManager/UserDataManager properties.

Tests:
- UserDataManagerTests.GetUserData_NullUser_ThrowsArgumentNullException:
  reproduces the crash (NRE -> now ArgumentNullException). Added to master's
  existing UserDataManagerTests.
- LibraryManagerSortTests.Sort_UserDependentKey_NullUser_FallsBackToSortNameWithoutThrowing:
  Sort with a user-dependent key + null user no longer throws and returns
  items ordered by the SortName fallback (direction preserved).
- LibraryManagerSortTests.Sort_DateLastContentAdded_NullUser_OrdersByDateNotSortName:
  guards that DateLastContentAdded still sorts by date with no user (fixture
  chosen so date-desc and SortName-desc disagree, so a revert is caught).

Full Jellyfin.Server.Implementations.Tests suite: 642 passed, 0 failed.

Fixes #17393
2026-07-22 07:43:58 +02:00
Shadowghost ca1f7af445 Fix incorrect year on local trailers 2026-07-21 20:27:04 +02:00
WizardOfYendor1 0c7428f136 Added verbose, rambling, log warning to help users with config issues (hoping to reduce false issues reports).
Also added a test to exercise it, which is perhaps silly but convenient.
2026-07-17 14:59:46 -04:00
WizardOfYendor1 97e666c566 Append base URL if the published server URL override omits it. Fleshed out unit tests to cover that and https->http reverse proxy scenario(s). 2026-07-17 14:59:46 -04:00
Enea D'Angiò d36c8ebce8 Reduce cognitive complexity of RemoveFromPlaylist 2026-07-16 21:03:29 +02:00
TowyTowy 5be844e1b7 Fix 3D format detection when the tag is the last token of the path
Format3DParser drops the last character of the final path token: when
IndexOfAny finds no more delimiters, the slice is taken with
'index = path.Length - 1', so e.g. "hsbs" is compared as "hsb" and
never matches any rule.

File paths are unaffected because the extension is always the final
token, but directory based media have no extension. For DVD/BluRay
folder rips (BaseVideoResolver parses the folder path via
Set3DFormat), a trailing 3D tag such as
"Gravity (2013) 3d hsbs/BDMV" is silently ignored and Video3DFormat
is never set.

This is a regression from 42a2cc174 which replaced the string.Split
based FlagParser with span slicing; the Split implementation kept the
final token intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-12 12:11:29 +02:00
WizardOfYendor1 6f189bf2b8 Reduce GetPlaybackInfo cognitive complexity 2026-07-11 10:12:42 -04:00
WizardOfYendor1 f3ff7a446b Add WizardOfYendor1 to contributors 2026-07-10 20:31:14 -04:00
WizardOfYendor1 a3ec1a3712 Add Live TV published URL regression coverage 2026-07-10 20:27:10 -04:00
WizardOfYendor1 6bbd6dcd44 Resolve Live TV client stream URLs per request 2026-07-10 20:26:29 -04:00
WizardOfYendor1 ce43df6f43 Fix host and port handling for published server URI overrides 2026-07-10 14:19:08 -04:00
Enea D'Angiò 6883cd0969 Fix play queue index handling in SyncPlay
Three index bugs in PlayQueueManager, two of which leave
PlayingItemIndex out of bounds, making every subsequent Buffering/Ready
request throw and leaving the group unusable until it empties:

- RemoveFromPlaylist did not compensate for removed items preceding the
  playing item: removing the playing item together with earlier items
  could select the wrong item or crash with an out-of-bounds index.
- Next/Previous on an empty playlist with RepeatOne/RepeatAll reported
  success or set PlayingItemIndex to 0 on an empty list, crashing
  downstream in Group and corrupting the index.
- SetPlayingItemByIndex accepted an index equal to the playlist count
  (latent off-by-one, callers currently pre-validate).
2026-07-04 11:38:05 +02:00
Shadowghost 5221584b06 Only process items with tmdb id 2026-06-25 20:54:05 +02:00
Shadowghost c4ace4ac95 Set TMDb id in season provider 2026-06-25 19:29:52 +02:00
Shadowghost 2fcf4084f8 Add TMDb missing episode provider 2026-06-25 19:10:38 +02:00
Elio Kuster 1bfbad2420 Fix missing collection folder posters after initial scans.
Fixes #1200
2026-06-09 20:34:24 +02:00
214 changed files with 15618 additions and 1571 deletions
+3 -3
View File
@@ -32,13 +32,13 @@ jobs:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/autobuild@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
--verbosity minimal
- name: Merge code coverage results
uses: danielpalme/ReportGenerator-GitHub-Action@049f7ec958c672fd31d5cc1cb01622dc8d2e23ab # v5.5.10
uses: danielpalme/ReportGenerator-GitHub-Action@d3ebf1f760f7d8ab92cc44d9bcfee7ad73722a31 # v5.5.11
with:
reports: "**/coverage.cobertura.xml"
targetdir: "merged/"
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
runs-on: ubuntu-latest
if: ${{ contains(github.repository, 'jellyfin/') }}
steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
with:
repo-token: ${{ secrets.JF_BOT_TOKEN }}
ascending: true
+4
View File
@@ -172,6 +172,7 @@
- [whooo](https://github.com/whooo)
- [WiiPlayer2](https://github.com/WiiPlayer2)
- [WillWill56](https://github.com/WillWill56)
- [WizardOfYendor1](https://github.com/WizardOfYendor1)
- [wtayl0r](https://github.com/wtayl0r)
- [Wuerfelbecher](https://github.com/Wuerfelbecher)
- [Wunax](https://github.com/Wunax)
@@ -233,6 +234,9 @@
- [MSalman5230](https://github.com/MSalman5230)
- [dwandw](https://github.com/dwandw)
- [Lampan-git](https://github.com/Lampan-git)
- [elio42](https://github.com/elio42)
- [rwebster85](https://github.com/rwebster85)
- [Florin-Popescu](https://github.com/Florin-Popescu)
# Emby Contributors
+2 -2
View File
@@ -17,7 +17,7 @@
<PackageVersion Include="Diacritics" Version="4.1.8" />
<PackageVersion Include="DiscUtils.Udf" Version="0.16.13" />
<PackageVersion Include="DotNet.Glob" Version="3.1.3" />
<PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.3" />
<PackageVersion Include="FsCheck.Xunit.v3" Version="3.3.4" />
<PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="8.3.1.5" />
<PackageVersion Include="ICU4N.Transliterator" Version="60.1.0-alpha.356" />
<PackageVersion Include="IDisposableAnalyzers" Version="4.0.8" />
@@ -68,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.50.0" />
<PackageVersion Include="SharpCompress" Version="0.50.4" />
<PackageVersion Include="SharpFuzz" Version="2.3.0" />
<PackageVersion Include="SkiaSharp" Version="3.119.4" />
<PackageVersion Include="SkiaSharp.HarfBuzz" Version="3.119.4" />
+4 -2
View File
@@ -57,7 +57,6 @@ namespace Emby.Naming.Common
".nrg",
".nsv",
".nuv",
".ogg",
".ogm",
".ogv",
".pva",
@@ -362,7 +361,10 @@ namespace Emby.Naming.Common
// Not a Kodi rule as well, but the expression below also causes false positives,
// so we make sure this one gets tested first.
// "Foo Bar 889"
new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$")
// Names carrying an SxxEyy marker are excluded because the Kodi expression above already covers them.
// Without that guard this expression reads digits out of the title instead, turning
// "S01E01 1-23-45 [Bluray-1080p]" into episodes 1 through 45.
new EpisodeExpression(@".*[\\\/](?![Ee]pisode)(?![^\\\/]*[Ss][0-9]+[][ ._-]*[Ee][0-9]+)(?<seriesname>[\w\s]+?)\s(?<epnumber>[0-9]{1,4})(-(?<endingepnumber>[0-9]{2,4}))*[^\\\/x]*$")
{
IsNamed = true
},
+9 -4
View File
@@ -52,13 +52,18 @@ namespace Emby.Naming.Video
while (path.Length > 0)
{
var index = path.IndexOfAny(delimiters);
ReadOnlySpan<char> currentSlice;
if (index == -1)
{
index = path.Length - 1;
// No delimiter left, the last token is the remainder of the path
currentSlice = path;
path = default;
}
else
{
currentSlice = path[..index];
path = path[(index + 1)..];
}
var currentSlice = path[..index];
path = path[(index + 1)..];
if (!foundPrefix)
{
@@ -987,8 +987,9 @@ namespace Emby.Server.Implementations
/// <inheritdoc/>
public string GetLocalApiUrl(string hostname, string scheme = null, int? port = null)
{
// If the smartAPI doesn't start with http then treat it as a host or ip.
if (hostname.StartsWith("http", StringComparison.OrdinalIgnoreCase))
// If the smartAPI isn't already a complete URL then treat it as a host or ip.
if (hostname.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
|| hostname.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return hostname.TrimEnd('/');
}
+88 -21
View File
@@ -242,6 +242,29 @@ namespace Emby.Server.Implementations.Dto
artistsBatch = _libraryManager.GetArtists(artistNames.ToArray());
}
// Batch-fetch people across all items to avoid one GetPeople query per item.
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null;
if (options.ContainsField(ItemFields.People))
{
var peopleItemIds = accessibleItems.Where(i => i.SupportsPeople).Select(i => i.Id).ToList();
if (peopleItemIds.Count > 0)
{
peopleBatch = _libraryManager.GetPeopleByItems(peopleItemIds);
}
}
// Batch-detect which videos own alternate versions to avoid the per-item alternate-version
// queries in MediaSourceCount. Videos absent from this set have a single media source.
IReadOnlySet<Guid>? alternateVersionItemIds = null;
if (options.ContainsField(ItemFields.MediaSourceCount))
{
var versionItemIds = accessibleItems.OfType<Video>().Select(i => i.Id).ToList();
if (versionItemIds.Count > 0)
{
alternateVersionItemIds = _libraryManager.GetItemIdsWithAlternateVersions(versionItemIds);
}
}
for (int index = 0; index < accessibleItems.Count; index++)
{
var item = accessibleItems[index];
@@ -255,7 +278,9 @@ namespace Emby.Server.Implementations.Dto
childCountBatch,
playedCountBatch,
artistsBatch,
resumeDataBatch?.GetValueOrDefault(item.Id));
resumeDataBatch?.GetValueOrDefault(item.Id),
peopleBatch,
alternateVersionItemIds);
if (item is LiveTvChannel tvChannel)
{
@@ -317,7 +342,9 @@ namespace Emby.Server.Implementations.Dto
Dictionary<Guid, int>? childCountBatch = null,
Dictionary<Guid, (int Played, int Total)>? playedCountBatch = null,
IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null,
VersionResumeData? resumeData = null)
VersionResumeData? resumeData = null,
IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>>? peopleBatch = null,
IReadOnlySet<Guid>? alternateVersionItemIds = null)
{
var dto = new BaseItemDto
{
@@ -331,7 +358,15 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.People))
{
AttachPeople(dto, item, user);
IReadOnlyList<PersonInfo>? prefetchedPeople = null;
if (peopleBatch is not null)
{
// The batch omits items with no people, so a miss means "no people",
// not "not fetched". Use an empty list to skip the per-item query.
prefetchedPeople = peopleBatch.GetValueOrDefault(item.Id) ?? [];
}
AttachPeople(dto, item, user, prefetchedPeople);
}
if (options.ContainsField(ItemFields.PrimaryImageAspectRatio))
@@ -378,7 +413,7 @@ namespace Emby.Server.Implementations.Dto
AttachStudios(dto, item);
}
AttachBasicFields(dto, item, owner, options, artistsBatch, user);
AttachBasicFields(dto, item, owner, options, artistsBatch, user, alternateVersionItemIds);
if (options.ContainsField(ItemFields.CanDelete))
{
@@ -742,12 +777,18 @@ namespace Emby.Server.Implementations.Dto
/// <param name="dto">The dto.</param>
/// <param name="item">The item.</param>
/// <param name="user">The requesting user.</param>
private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null)
/// <param name="prefetchedPeople">People fetched in batch by the caller; when null the people are queried per item.</param>
private void AttachPeople(BaseItemDto dto, BaseItem item, User? user = null, IReadOnlyList<PersonInfo>? prefetchedPeople = null)
{
// When rendering a page of items the caller batch-fetches people for every item up
// front and passes them in, avoiding one GetPeople query per item. Fall back to the
// per-item query for the single item path where no batch is available.
var source = prefetchedPeople ?? _libraryManager.GetPeople(item);
// Ordering by person type to ensure actors and artists are at the front.
// This is taking advantage of the fact that they both begin with A
// This should be improved in the future
var people = _libraryManager.GetPeople(item).OrderBy(i => i.SortOrder ?? int.MaxValue)
var people = source.OrderBy(i => i.SortOrder ?? int.MaxValue)
.ThenBy(i =>
{
if (i.IsType(PersonKind.Actor))
@@ -957,7 +998,8 @@ namespace Emby.Server.Implementations.Dto
/// <param name="options">The options.</param>
/// <param name="artistsBatch">Optional pre-fetched artist lookup shared across a batch of items.</param>
/// <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)
/// <param name="alternateVersionItemIds">Optional pre-fetched set of item IDs that own alternate versions, shared across a batch of items.</param>
private void AttachBasicFields(BaseItemDto dto, BaseItem item, BaseItem? owner, DtoOptions options, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch = null, User? user = null, IReadOnlySet<Guid>? alternateVersionItemIds = null)
{
if (options.ContainsField(ItemFields.DateCreated))
{
@@ -1088,7 +1130,7 @@ namespace Emby.Server.Implementations.Dto
dto.ParentId = item.DisplayParentId;
}
AddInheritedImages(dto, item, options, owner);
AddInheritedImages(dto, item, options, owner, artistsBatch);
if (options.ContainsField(ItemFields.Path))
{
@@ -1271,15 +1313,27 @@ namespace Emby.Server.Implementations.Dto
if (options.ContainsField(ItemFields.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)
// A video with no primary version and no alternate versions always has a single
// media source. Only compute the count for videos that might have more: a primary
// version, or membership in the batch's set of items that own alternate versions.
// Without the batch we can't rule it out, so fall back to computing (the single-item
// path). Everything else is the common case and keeps the default count of one.
var mayHaveAlternateVersions = alternateVersionItemIds is null
|| video.PrimaryVersionId.HasValue
|| alternateVersionItemIds.Contains(video.Id);
if (mayHaveAlternateVersions)
{
dto.MediaSourceCount = 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;
}
}
}
@@ -1519,11 +1573,11 @@ namespace Emby.Server.Implementations.Dto
}
}
private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem)
private BaseItem? GetImageDisplayParent(BaseItem currentItem, BaseItem originalItem, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
{
if (currentItem is MusicAlbum musicAlbum)
{
var artist = musicAlbum.GetMusicArtist(new DtoOptions(false));
var artist = GetBatchedAlbumArtist(musicAlbum, artistsBatch) ?? musicAlbum.GetMusicArtist(new DtoOptions(false));
if (artist is not null)
{
return artist;
@@ -1540,7 +1594,20 @@ namespace Emby.Server.Implementations.Dto
return parent;
}
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner)
private static MusicArtist? GetBatchedAlbumArtist(MusicAlbum album, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
{
if (artistsBatch is null)
{
return null;
}
var name = album.AlbumArtists.Count > 0 ? album.AlbumArtists[0] : null;
return !string.IsNullOrEmpty(name) && artistsBatch.TryGetValue(name, out var artists) && artists.Length > 0
? artists[0]
: null;
}
private void AddInheritedImages(BaseItemDto dto, BaseItem item, DtoOptions options, BaseItem? owner, IReadOnlyDictionary<string, MusicArtist[]>? artistsBatch)
{
if (item is UserView { ViewType: CollectionType.playlists } playlistsView
&& options.GetImageLimit(ImageType.Primary) > 0
@@ -1585,7 +1652,7 @@ namespace Emby.Server.Implementations.Dto
|| (!(imageTags is not null && imageTags.ContainsKey(ImageType.Thumb)) && thumbLimit > 0)
|| parent is Series)
{
parent ??= isFirst ? GetImageDisplayParent(item, item) ?? owner : parent;
parent ??= isFirst ? GetImageDisplayParent(item, item, artistsBatch) ?? owner : parent;
if (parent is null)
{
break;
@@ -1644,7 +1711,7 @@ namespace Emby.Server.Implementations.Dto
break;
}
parent = GetImageDisplayParent(parent, item);
parent = GetImageDisplayParent(parent, item, artistsBatch);
}
}
@@ -45,6 +45,7 @@ using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Drawing;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Library;
using MediaBrowser.Model.Querying;
@@ -86,6 +87,7 @@ namespace Emby.Server.Implementations.Library
private readonly IPeopleRepository _peopleRepository;
private readonly ExtraResolver _extraResolver;
private readonly IPathManager _pathManager;
private readonly ILocalizationManager _localization;
private readonly FastConcurrentLru<Guid, BaseItem> _cache;
private readonly DotIgnoreIgnoreRule _dotIgnoreIgnoreRule;
private readonly IMediaStreamRepository _mediaStreamRepository;
@@ -132,6 +134,7 @@ namespace Emby.Server.Implementations.Library
/// <param name="peopleRepository">The people repository.</param>
/// <param name="pathManager">The path manager.</param>
/// <param name="dotIgnoreIgnoreRule">The .ignore rule handler.</param>
/// <param name="localization">The localization manager.</param>
/// <param name="mediaStreamRepository">The media stream repository.</param>
/// <param name="externalDataManagerFactory">The external data manager (lazy, to break the DI cycle through ChapterManager).</param>
public LibraryManager(
@@ -157,6 +160,7 @@ namespace Emby.Server.Implementations.Library
IPeopleRepository peopleRepository,
IPathManager pathManager,
DotIgnoreIgnoreRule dotIgnoreIgnoreRule,
ILocalizationManager localization,
IMediaStreamRepository mediaStreamRepository,
Lazy<IExternalDataManager> externalDataManagerFactory)
{
@@ -184,6 +188,7 @@ namespace Emby.Server.Implementations.Library
_peopleRepository = peopleRepository;
_pathManager = pathManager;
_dotIgnoreIgnoreRule = dotIgnoreIgnoreRule;
_localization = localization;
_extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService);
_configurationManager.ConfigurationUpdated += ConfigurationUpdated;
@@ -407,6 +412,13 @@ namespace Emby.Server.Implementations.Library
}
_persistenceService.DeleteItem([.. pathMaps.Select(f => f.Item.Id)]);
// Evict the deleted items from the cache and announce each removal.
foreach (var (item, _, _) in pathMaps)
{
_cache.TryRemove(item.Id, out _);
ReportItemRemoved(item, item.GetOwner() ?? item.GetParent());
}
}
public void DeleteItem(BaseItem item, DeleteOptions options, BaseItem parent, bool notifyParentItem)
@@ -606,6 +618,12 @@ namespace Emby.Server.Implementations.Library
folder.UserData = null;
}
// Announce the descendants before the item itself.
foreach (var child in children)
{
ReportItemRemoved(child, item);
}
ReportItemRemoved(item, parent);
}
@@ -2229,6 +2247,12 @@ namespace Emby.Server.Implementations.Library
return [];
}
/// <inheritdoc />
public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds)
{
return _linkedChildrenService.GetItemIdsWithAlternateVersions(itemIds);
}
/// <inheritdoc />
public void UpsertLinkedChild(Guid parentId, Guid childId, MediaBrowser.Controller.Entities.LinkedChildType childType)
{
@@ -2315,9 +2339,13 @@ namespace Emby.Server.Implementations.Library
{
var comparer = Comparers.FirstOrDefault(c => name == c.Type);
// If it requires a user, create a new one, and assign the user
if (comparer is IUserBaseItemComparer)
{
if (user is null)
{
throw new ArgumentException($"Sort key '{name}' requires a user, but none was provided.");
}
var userComparer = (IUserBaseItemComparer)Activator.CreateInstance(comparer.GetType())!; // only null for Nullable<T> instances
userComparer.User = user;
@@ -2367,6 +2395,7 @@ namespace Emby.Server.Implementations.Library
{
altVideo.OwnerId = video.Id;
altVideo.SetPrimaryVersionId(video.Id);
altVideo.IsInMixedFolder = video.IsInMixedFolder;
// ResolveAlternateVersion only sees the alternate's primary file.
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
// detect its parts from sibling files so its AdditionalParts persist.
@@ -2552,6 +2581,8 @@ namespace Emby.Server.Implementations.Library
item.DateLastSaved = DateTime.UtcNow;
}
ForgetDroppedLocalAlternateVersions(items);
// Resolve and add any local alternate version items that don't exist yet
// This ensures they exist in the database when LinkedChildren are processed
var allItems = new List<BaseItem>(items);
@@ -2580,6 +2611,7 @@ namespace Emby.Server.Implementations.Library
{
altVideo.OwnerId = video.Id;
altVideo.SetPrimaryVersionId(video.Id);
altVideo.IsInMixedFolder = video.IsInMixedFolder;
// ResolveAlternateVersion only sees the alternate's primary file.
// If the alternate is itself a stack (e.g. 1080p part1 + part2),
// detect its parts from sibling files so its AdditionalParts persist.
@@ -2640,6 +2672,30 @@ namespace Emby.Server.Implementations.Library
public Task UpdateItemAsync(BaseItem item, BaseItem parent, ItemUpdateType updateReason, CancellationToken cancellationToken)
=> UpdateItemsAsync([item], parent, updateReason, cancellationToken);
/// <summary>
/// Forgets the cached local alternate versions of the supplied items that they no longer list.
/// </summary>
/// <param name="items">The items about to be saved.</param>
private void ForgetDroppedLocalAlternateVersions(IReadOnlyList<BaseItem> items)
{
foreach (var video in items.OfType<Video>())
{
var videoType = video.GetType();
var keptIds = video.LocalAlternateVersions
.Where(path => !string.IsNullOrEmpty(path))
.Select(path => GetNewItemId(path, videoType))
.ToHashSet();
foreach (var versionId in GetLocalAlternateVersionIds(video))
{
if (!keptIds.Contains(versionId))
{
_cache.TryRemove(versionId, out _);
}
}
}
}
/// <inheritdoc />
public async Task ReattachUserDataAsync(BaseItem item, CancellationToken cancellationToken)
{
@@ -3195,11 +3251,11 @@ namespace Emby.Server.Implementations.Library
}
}
if (!episode.ProductionYear.HasValue)
if (episode.ProductionYear is null)
{
episode.ProductionYear = episodeInfo.Year;
if (episode.ProductionYear.HasValue)
if (episode.ProductionYear is not null)
{
changed = true;
}
@@ -3276,9 +3332,11 @@ namespace Emby.Server.Implementations.Library
var ownerVideoInfo = VideoResolver.Resolve(owner.Path, isFolder, _namingOptions, libraryRoot: owner.ContainingFolderPath);
if (ownerVideoInfo is null)
{
yield break;
return [];
}
var candidates = new List<ExtraCandidate>();
var count = filtered.Count;
for (var i = 0; i < count; i++)
{
@@ -3292,35 +3350,50 @@ namespace Emby.Server.Implementations.Library
foreach (var file in filesInSubFolderList)
{
if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType))
if (!_extraResolver.TryGetExtraTypeForOwner(file.FullName, ownerVideoInfo, out var extraType, out var extraRule))
{
continue;
}
var extra = GetExtra(file, extraType.Value, subFolderIsMixedFolder);
if (extra is not null)
{
yield return extra;
}
AddCandidate(file, extraType.Value, extraRule, subFolderIsMixedFolder);
}
}
else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType))
else if (!current.IsDirectory && _extraResolver.TryGetExtraTypeForOwner(current.FullName, ownerVideoInfo, out var extraType, out var extraRule))
{
var extra = GetExtra(current, extraType.Value, false);
if (extra is not null)
{
yield return extra;
}
AddCandidate(current, extraType.Value, extraRule, false);
}
}
BaseItem? GetExtra(FileSystemMetadata file, ExtraType extraType, bool isInMixedFolder)
var extras = new List<BaseItem>();
var typeCounters = new Dictionary<ExtraType, int>();
// Order by path so that the numbering handed out below does not depend on the
// order the file system happened to list the folder in
foreach (var candidate in candidates.OrderBy(c => c.Extra.Path, StringComparer.Ordinal))
{
var extra = PrepareExtra(candidate);
if (extra is not null)
{
extras.Add(extra);
}
}
return extras;
void AddCandidate(FileSystemMetadata file, ExtraType extraType, ExtraRule extraRule, bool isInMixedFolder)
{
var extra = ResolvePath(_fileSystem.GetFileInfo(file.FullName), directoryService, _extraResolver.GetResolversForExtraType(extraType));
if (extra is not Video && extra is not Audio)
if (extra is Video or Audio)
{
return null;
candidates.Add(new ExtraCandidate(extra, extraType, extraRule, isInMixedFolder));
}
}
BaseItem? PrepareExtra(ExtraCandidate candidate)
{
var resolved = candidate.Extra;
var extra = resolved;
var name = GetExtraName(candidate, ownerVideoInfo, typeCounters);
// Try to retrieve it from the db. If we don't find it, use the resolved version
var itemById = GetItemById(extra.Id);
@@ -3329,10 +3402,18 @@ namespace Emby.Server.Implementations.Library
extra = itemById;
}
// Only update extra type if it is more specific then the currently known extra type
if (extra.ExtraType is null or ExtraType.Unknown || extraType != ExtraType.Unknown)
// An extra is named after its file, so the file is the source of truth. Items created
// by older versions, or renamed by a metadata provider, are corrected here;
// RefreshExtras persists the change.
if (!string.IsNullOrEmpty(name) && extra.LockedFields?.Contains(MetadataField.Name) != true)
{
extra.ExtraType = extraType;
extra.Name = name;
}
// Only update extra type if it is more specific then the currently known extra type
if (extra.ExtraType is null or ExtraType.Unknown || candidate.ExtraType != ExtraType.Unknown)
{
extra.ExtraType = candidate.ExtraType;
}
// Only return items that are actual extras (have ExtraType set)
@@ -3340,7 +3421,7 @@ namespace Emby.Server.Implementations.Library
// so that RefreshExtras can detect when they need updating and set ForceSave.
if (extra.ExtraType is not null)
{
extra.IsInMixedFolder = isInMixedFolder;
extra.IsInMixedFolder = candidate.IsInMixedFolder;
return extra;
}
@@ -3348,6 +3429,57 @@ namespace Emby.Server.Implementations.Library
}
}
/// <summary>
/// Gets the name to give an extra.
/// </summary>
/// <param name="candidate">The resolved extra.</param>
/// <param name="ownerVideoInfo">The naming info of the owner.</param>
/// <param name="typeCounters">Number of extras named after their type so far, per type.</param>
/// <returns>The name.</returns>
private string GetExtraName(ExtraCandidate candidate, VideoFileInfo ownerVideoInfo, Dictionary<ExtraType, int> typeCounters)
{
var isNamedAfterOwner = candidate.ExtraRule.RuleType switch
{
ExtraRuleType.Filename => true,
ExtraRuleType.Suffix => string.Equals(candidate.Extra.Name, ownerVideoInfo.Name, StringComparison.OrdinalIgnoreCase),
_ => false
};
if (!isNamedAfterOwner)
{
return candidate.Extra.Name;
}
typeCounters.TryGetValue(candidate.ExtraType, out var seen);
typeCounters[candidate.ExtraType] = seen + 1;
var typeName = _localization.GetServerLocalizedString(GetExtraTypeNameKey(candidate.ExtraType));
return seen == 0
? typeName
: string.Format(
CultureInfo.InvariantCulture,
_localization.GetServerLocalizedString("NameExtraNumbered"),
typeName,
seen + 1);
}
private static string GetExtraTypeNameKey(ExtraType extraType) => extraType switch
{
ExtraType.Clip => "NameExtraClip",
ExtraType.Trailer => "NameExtraTrailer",
ExtraType.BehindTheScenes => "NameExtraBehindTheScenes",
ExtraType.DeletedScene => "NameExtraDeletedScene",
ExtraType.Interview => "NameExtraInterview",
ExtraType.Scene => "NameExtraScene",
ExtraType.Sample => "NameExtraSample",
ExtraType.ThemeSong => "NameExtraThemeSong",
ExtraType.ThemeVideo => "NameExtraThemeVideo",
ExtraType.Featurette => "NameExtraFeaturette",
ExtraType.Short => "NameExtraShort",
_ => "NameExtraUnknown"
};
public string GetPathAfterNetworkSubstitution(string path, BaseItem? ownerItem)
{
foreach (var map in _configurationManager.Configuration.PathSubstitutions)
@@ -3424,6 +3556,12 @@ namespace Emby.Server.Implementations.Library
return _peopleRepository.GetPeopleNamesByItems(itemIds, personTypes);
}
/// <inheritdoc/>
public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds)
{
return _peopleRepository.GetPeopleByItems(itemIds);
}
public void UpdatePeople(BaseItem item, List<PersonInfo> people)
{
UpdatePeopleAsync(item, people, CancellationToken.None).GetAwaiter().GetResult();
@@ -3898,5 +4036,7 @@ namespace Emby.Server.Implementations.Library
SetTopParentOrAncestorIds(query);
return _itemRepository.GetMediaStreamLanguages(query, mediaStreamType);
}
private sealed record ExtraCandidate(BaseItem Extra, ExtraType ExtraType, ExtraRule ExtraRule, bool IsInMixedFolder);
}
}
@@ -32,8 +32,8 @@ namespace Emby.Server.Implementations.Library.Resolvers
: base(logger, namingOptions, directoryService)
{
_namingOptions = namingOptions;
_trailerResolvers = new IItemResolver[] { new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService) };
_videoResolvers = new IItemResolver[] { this };
_trailerResolvers = [new GenericVideoResolver<Trailer>(logger, namingOptions, directoryService, parseName: true)];
_videoResolvers = [this];
}
protected override Video Resolve(ItemResolveArgs args)
@@ -54,12 +54,13 @@ namespace Emby.Server.Implementations.Library.Resolvers
_ => _videoResolvers
};
public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, string? libraryRoot = "")
public bool TryGetExtraTypeForOwner(string path, VideoFileInfo ownerVideoFileInfo, [NotNullWhen(true)] out ExtraType? extraType, [NotNullWhen(true)] out ExtraRule? extraRule, string? libraryRoot = "")
{
var extraResult = GetExtraInfo(path, _namingOptions, libraryRoot);
if (extraResult.ExtraType is null)
if (extraResult.ExtraType is null || extraResult.Rule is null)
{
extraType = null;
extraRule = null;
return false;
}
@@ -88,6 +89,7 @@ namespace Emby.Server.Implementations.Library.Resolvers
}
extraType = extraResult.ExtraType;
extraRule = extraResult.Rule;
return isValid;
}
@@ -2,6 +2,7 @@
using Emby.Naming.Common;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using Microsoft.Extensions.Logging;
@@ -14,15 +15,25 @@ namespace Emby.Server.Implementations.Library.Resolvers
public class GenericVideoResolver<T> : BaseVideoResolver<T>
where T : Video, new()
{
private readonly bool _parseName;
/// <summary>
/// Initializes a new instance of the <see cref="GenericVideoResolver{T}"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="namingOptions">The naming options.</param>
/// <param name="directoryService">The directory service.</param>
public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService)
/// <param name="parseName">Whether to parse the file name for metadata such as the year.</param>
public GenericVideoResolver(ILogger logger, NamingOptions namingOptions, IDirectoryService directoryService, bool parseName = false)
: base(logger, namingOptions, directoryService)
{
_parseName = parseName;
}
/// <inheritdoc />
protected override T Resolve(ItemResolveArgs args)
{
return ResolveVideo<T>(args, _parseName);
}
}
}
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Emby.Server.Implementations.Playlists;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Library;
@@ -46,6 +47,19 @@ namespace Emby.Server.Implementations.Library.Resolvers
};
}
// Anything directly inside the internal playlists folder is a playlist, even when its
// playlist.xml is missing: failing to resolve here makes the library scan treat the
// playlist as deleted from disk and remove it, taking its items with it.
if (args.Parent is PlaylistsFolder)
{
return new Playlist
{
Path = args.Path,
Name = filename,
OpenAccess = true
};
}
// It's a directory-based playlist if the directory contains a playlist file
IEnumerable<string> filePaths;
try
@@ -92,49 +92,46 @@ public class SearchManager : ISearchManager
await Task.WhenAll(externalTask, internalTask).ConfigureAwait(false);
var externalResults = await externalTask.ConfigureAwait(false);
var fromExternal = externalResults.Count > 0;
IReadOnlyList<SearchResult> results;
if (fromExternal)
{
results = externalResults;
}
else
{
results = await internalTask.ConfigureAwait(false);
if (_internalProviders.Length > 0)
{
_logger.LogDebug("No results from external providers, using internal provider results");
}
}
// Internal providers apply user-access filtering inline in their queries. External
// providers don't know about user permissions, so they may return IDs from hidden
// libraries or items the user is otherwise blocked from. Run the post-filter only
// when results came from externals to close that gap. The Items controller's second
// roundtrip via folder.GetItems applies most of these again, but it does not restrict
// by TopParentIds when ItemIds is set.
if (fromExternal && results.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
// libraries or items the user is otherwise blocked from. Filter them here to close
// that gap. The Items controller's second roundtrip via folder.GetItems applies most
// of these again, but it does not restrict by TopParentIds when ItemIds is set.
if (externalResults.Count > 0 && query.UserId.HasValue && !query.UserId.Value.IsEmpty())
{
var user = _userManager.GetUserById(query.UserId.Value);
if (user is not null)
{
results = await FilterByUserAccessAsync(results, user, cancellationToken).ConfigureAwait(false);
externalResults = await FilterByUserAccessAsync(externalResults, user, query, cancellationToken).ConfigureAwait(false);
}
}
return results;
if (externalResults.Count > 0)
{
return externalResults;
}
var internalResults = await internalTask.ConfigureAwait(false);
if (_internalProviders.Length > 0)
{
_logger.LogDebug("No results from external providers, using internal provider results");
}
return internalResults;
}
private async Task<IReadOnlyList<SearchResult>> FilterByUserAccessAsync(
IReadOnlyList<SearchResult> candidates,
User user,
SearchProviderQuery query,
CancellationToken cancellationToken)
{
// SetUser populates parental rating + blocked/allowed tags. ConfigureUserAccess populates
// TopParentIds for the user's accessible libraries — we call it before assigning ItemIds
// because LibraryManager.AddUserToQuery skips TopParentIds when ItemIds is non-empty.
var accessFilter = new InternalItemsQuery(user);
_libraryManager.ConfigureUserAccess(accessFilter, user);
// SetUser populates parental rating + blocked/allowed tags, Build populates TopParentIds
// for the user's accessible libraries. The candidate ids are applied to the query below
// rather than to the filter because LibraryManager.AddUserToQuery skips TopParentIds when
// ItemIds is non-empty.
var accessFilter = SearchQueryAccessFilter.Build(user, query, _libraryManager);
Guid[] candidateIds = [.. candidates.Select(c => c.ItemId)];
@@ -0,0 +1,38 @@
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
namespace Emby.Server.Implementations.Library.Search;
/// <summary>
/// Builds the access filter that decides which items a search may return for a user.
/// </summary>
internal static class SearchQueryAccessFilter
{
/// <summary>
/// Builds an access filter carrying the search's library access and type filters.
/// </summary>
/// <param name="user">The user the search runs for.</param>
/// <param name="query">The search query.</param>
/// <param name="libraryManager">The library manager.</param>
/// <returns>The access filter.</returns>
public static InternalItemsQuery Build(User user, SearchProviderQuery query, ILibraryManager libraryManager)
{
// The type filters have to travel with the access filter: a by-name item belongs to no
// library, so it carries no TopParentId to match, and the library filter only knows to
// exempt it when the query says those types are wanted. A search scoped to a parent gets
// no exemption because a by-name item has no parent to descend from either.
var accessFilter = new InternalItemsQuery(user)
{
IncludeItemTypes = query.IncludeItemTypes,
ExcludeItemTypes = query.ExcludeItemTypes,
IncludeItemsByName = !query.ParentId.HasValue || query.ParentId.Value.IsEmpty()
};
// ConfigureUserAccess populates TopParentIds for the libraries the user may open.
libraryManager.ConfigureUserAccess(accessFilter, user);
return accessFilter;
}
}
@@ -114,7 +114,7 @@ public class SqlSearchProvider : IInternalSearchProvider
dbQuery = ApplyTypeFilter(dbQuery, query.IncludeItemTypes, query.ExcludeItemTypes);
dbQuery = ApplyMediaTypeFilter(dbQuery, query.MediaTypes);
dbQuery = ApplyParentFilter(dbQuery, query.ParentId);
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query.UserId);
dbQuery = ApplyUserAccessFilter(dbContext, dbQuery, query);
// Compute the score in SQL: the ternary translates to a CASE WHEN. CleanName is
// the pre-normalized (lowercase, diacritic-stripped) form, so we score against it
@@ -196,8 +196,9 @@ public class SqlSearchProvider : IInternalSearchProvider
private IQueryable<BaseItemEntity> ApplyUserAccessFilter(
JellyfinDbContext dbContext,
IQueryable<BaseItemEntity> query,
Guid? userId)
SearchProviderQuery searchQuery)
{
var userId = searchQuery.UserId;
if (!userId.HasValue || userId.Value.IsEmpty())
{
return query;
@@ -209,8 +210,7 @@ public class SqlSearchProvider : IInternalSearchProvider
return query;
}
var accessFilter = new InternalItemsQuery(user);
_libraryManager.ConfigureUserAccess(accessFilter, user);
var accessFilter = SearchQueryAccessFilter.Build(user, searchQuery, _libraryManager);
return _queryHelpers.ApplyAccessFiltering(dbContext, query, accessFilter);
}
@@ -183,6 +183,7 @@ public class SimilarItemsManager : ISimilarItemsManager
// Collect references in batches and resolve against local library.
// Stop fetching once we have enough resolved local items.
const int BatchSize = 20;
const int MaxRemoteReferenceFetchLimit = 500;
var remaining = requestedLimit - allResults.Count;
var collectedReferences = new List<SimilarItemReference>();
var pendingBatch = new List<SimilarItemReference>();
@@ -199,7 +200,7 @@ public class SimilarItemsManager : ISimilarItemsManager
remaining -= resolvedItems.Count;
pendingBatch.Clear();
if (remaining <= 0)
if (remaining <= 0 || collectedReferences.Count >= MaxRemoteReferenceFetchLimit)
{
break;
}
@@ -352,6 +352,7 @@ namespace Emby.Server.Implementations.Library
/// <inheritdoc />
public UserItemData? GetUserData(User user, BaseItem item)
{
ArgumentNullException.ThrowIfNull(user);
var row = ResolveUserDataRow(item, item.UserData?.Where(e => e.UserId.Equals(user.Id)));
return row is not null ? Map(row) : new UserItemData()
{
@@ -0,0 +1,64 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using Microsoft.Extensions.Logging;
namespace Emby.Server.Implementations.Library.Validators;
/// <summary>
/// Ensures top-level library folders have a primary poster after scans.
/// Poster extraction is attempted before library scanning. When a library is
/// empty at that point, no poster can be extracted. This post-scan task reruns
/// metadata extraction for top-level folders that are still missing images.
/// </summary>
public class CollectionPosterVerifyPostScanTask : ILibraryPostScanTask
{
private readonly ILibraryManager _libraryManager;
private readonly ILogger<CollectionPosterVerifyPostScanTask> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="CollectionPosterVerifyPostScanTask" /> class.
/// </summary>
/// <param name="libraryManager">The library manager.</param>
/// <param name="logger">The logger.</param>
public CollectionPosterVerifyPostScanTask(
ILibraryManager libraryManager,
ILogger<CollectionPosterVerifyPostScanTask> logger)
{
_libraryManager = libraryManager;
_logger = logger;
}
/// <summary>
/// Runs the specified progress.
/// </summary>
/// <param name="progress">The progress.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>Task.</returns>
public async Task Run(IProgress<double> progress, CancellationToken cancellationToken)
{
var libraries = _libraryManager.GetUserRootFolder().Children.OfType<CollectionFolder>().ToList();
var totalLibraries = libraries.Count;
var processedLibraries = 0;
foreach (var library in libraries)
{
cancellationToken.ThrowIfCancellationRequested();
if (!library.HasImage(ImageType.Primary))
{
_logger.LogDebug("Library {LibraryName} is missing a primary image. Refreshing metadata.", library.Name);
await library.RefreshMetadata(cancellationToken).ConfigureAwait(false);
}
processedLibraries++;
progress.Report((double)processedLibraries / totalLibraries * 100);
}
progress.Report(100);
}
}
@@ -100,7 +100,7 @@
"TaskAudioNormalization": "تطبيع الصوت",
"TaskAudioNormalizationDescription": "يفحص الملفات لجمع بيانات تطبيع الصوت.",
"TaskDownloadMissingLyrics": "تنزيل الكلمات المفقودة",
"TaskDownloadMissingLyricsDescription": "ينزّل الكلمات للأغاني.",
"TaskDownloadMissingLyricsDescription": "تحميل كلمات الأغاني",
"TaskExtractMediaSegments": "فحص مقاطع المحتوى",
"TaskExtractMediaSegmentsDescription": "يستخرج أو يحصل على مقاطع المحتوى من الملحقات المفعّلة لمقاطع المحتوى (MediaSegment).",
"TaskMoveTrickplayImages": "نقل موقع صور معاينات التنقل",
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "مهمة تنظيف بيانات المستخدم",
"CleanupUserDataTaskDescription": "ينظف جميع بيانات المستخدم (مثل حالة المشاهدة وحالة المفضلة وغيرها) للمحتوى الذي لم يعد موجوداً لمدة 90 يوماً على الأقل.",
"Original": "فريد",
"LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}"
"LyricDownloadFailureFromForItem": "فشل تحميل الكلمات من {0} إلى {1}",
"NameExtraBehindTheScenes": "خلف المشاهد",
"NameExtraClip": "مقطع",
"NameExtraDeletedScene": "المشهد المحذوف",
"NameExtraInterview": "مقابلة",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "عيّنة",
"NameExtraScene": "مشهد",
"NameExtraShort": "قصير",
"NameExtraThemeSong": "الاغنية السمة",
"NameExtraThemeVideo": "الفيديو السمة",
"NameExtraFeaturette": "فيلم قصير إضافي",
"NameExtraTrailer": "إعلان ترويجي",
"NameExtraUnknown": "إضافي"
}
@@ -108,5 +108,18 @@
"CleanupUserDataTaskDescription": "Neteja totes les dades d'usuari (estat de la visualització, estat dels preferits, etc.) del contingut multimèdia que no ha estat present durant almenys 90 dies.",
"CleanupUserDataTask": "Tasca de neteja de dades d'usuari",
"Original": "Original",
"LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}"
"LyricDownloadFailureFromForItem": "No s'han pogut descarregar les lletres des de {0} per a {1}",
"NameExtraBehindTheScenes": "Rere les càmeres",
"NameExtraClip": "Tall",
"NameExtraDeletedScene": "Escena eliminada",
"NameExtraFeaturette": "Migmetratge",
"NameExtraInterview": "Entrevista",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Mostra",
"NameExtraScene": "Escena",
"NameExtraShort": "Curt",
"NameExtraThemeSong": "Tema musical",
"NameExtraThemeVideo": "Vídeo temàtic",
"NameExtraTrailer": "Tràiler",
"NameExtraUnknown": "Extra"
}
@@ -108,5 +108,18 @@
"CleanupUserDataTaskDescription": "Odstraní všechna uživatelská data (stav zhlédnutí, oblíbené atd.) z médií, které již neexistují více než 90 dní.",
"CleanupUserDataTask": "Pročistit uživatelská data",
"Original": "Originál",
"LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}"
"LyricDownloadFailureFromForItem": "Nepodařilo se stáhnout texty pro {1} ze služby {0}",
"NameExtraBehindTheScenes": "Zákulisí",
"NameExtraClip": "Klip",
"NameExtraDeletedScene": "Vymazaná scéna",
"NameExtraFeaturette": "Featurette",
"NameExtraInterview": "Rozhovor",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Ukázka",
"NameExtraScene": "Scéna",
"NameExtraShort": "Krátké",
"NameExtraThemeSong": "Úvodní píseň",
"NameExtraThemeVideo": "Úvodní video",
"NameExtraTrailer": "Upoutávka",
"NameExtraUnknown": "Extra"
}
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "Brugerdata oprydningsopgave",
"CleanupUserDataTaskDescription": "Rydder alle brugerdata (eks. visning- og favoritstatus) fra medier, der har været utilgængelige i mindst 90 dage.",
"LyricDownloadFailureFromForItem": "Sangtekster kunne ikke downloades fra {0} til {1}",
"Original": "Original"
"Original": "Original",
"NameExtraBehindTheScenes": "Bag Scenerne",
"NameExtraClip": "Klip",
"NameExtraDeletedScene": "Slettet Scene",
"NameExtraFeaturette": "Featurette",
"NameExtraInterview": "Interview",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Smagsprøve",
"NameExtraScene": "Scene",
"NameExtraShort": "Kort",
"NameExtraThemeSong": "Tema Sang",
"NameExtraThemeVideo": "Tema Video",
"NameExtraTrailer": "Trailer",
"NameExtraUnknown": "Ekstra"
}
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "Aufgabe zur Bereinigung von Benutzerdaten",
"CleanupUserDataTaskDescription": "Löscht alle Benutzerdaten (Abspielstatus, Favoritenstatus, usw.) von Medien, die seit mindestens 90 Tagen nicht mehr vorhanden sind.",
"Original": "Original",
"LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}"
"LyricDownloadFailureFromForItem": "Fehler beim Download der Songtexte von {0} für {1}",
"NameExtraBehindTheScenes": "Behind The Scenes",
"NameExtraDeletedScene": "Entfernte Szene",
"NameExtraInterview": "Interview",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Ausschnitt",
"NameExtraScene": "Szene",
"NameExtraShort": "Kurzfilm",
"NameExtraThemeSong": "Titellied",
"NameExtraThemeVideo": "Titelvideo",
"NameExtraTrailer": "Trailer",
"NameExtraUnknown": "Extra",
"NameExtraClip": "Clip",
"NameExtraFeaturette": "Hinter den Kulissen"
}
@@ -28,6 +28,19 @@
"Movies": "Movies",
"Music": "Music",
"MusicVideos": "Music Videos",
"NameExtraBehindTheScenes": "Behind The Scenes",
"NameExtraClip": "Clip",
"NameExtraDeletedScene": "Deleted Scene",
"NameExtraFeaturette": "Featurette",
"NameExtraInterview": "Interview",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Sample",
"NameExtraScene": "Scene",
"NameExtraShort": "Short",
"NameExtraThemeSong": "Theme Song",
"NameExtraThemeVideo": "Theme Video",
"NameExtraTrailer": "Trailer",
"NameExtraUnknown": "Extra",
"NameInstallFailed": "{0} installation failed",
"NameSeasonNumber": "Season {0}",
"NameSeasonUnknown": "Season Unknown",
@@ -108,5 +108,18 @@
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, estado de los favoritos, etc.) que no están presentes en la biblioteca por al menos 90 días.",
"CleanupUserDataTask": "Tarea de limpieza de datos de usuarios",
"LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}",
"Original": "Original"
"Original": "Original",
"NameExtraBehindTheScenes": "Detrás de cámaras",
"NameExtraClip": "Clip",
"NameExtraDeletedScene": "Escena eliminada",
"NameExtraFeaturette": "Minidocumental",
"NameExtraInterview": "Entrevista",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Muestra",
"NameExtraScene": "Escena",
"NameExtraShort": "Cortometraje",
"NameExtraThemeSong": "Música de presentación",
"NameExtraThemeVideo": "Video de presentación",
"NameExtraTrailer": "Tráiler",
"NameExtraUnknown": "Extra"
}
@@ -106,5 +106,20 @@
"TaskMoveTrickplayImages": "Migrar la ubicación de la imagen de Trickplay",
"TaskMoveTrickplayImagesDescription": "Mueve archivos de trickplay existentes según la configuración de la biblioteca.",
"CleanupUserDataTask": "Tarea de limpieza de los datos del usuario",
"CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días."
"CleanupUserDataTaskDescription": "Limpia toda la información de usuario (Estado de última vez visto, favoritos, etc) del archivo media que no está presente por los últimos 90 días.",
"LyricDownloadFailureFromForItem": "No se pudo descargar la letra desde {0} para {1}",
"NameExtraBehindTheScenes": "Detrás de cámaras",
"NameExtraClip": "Clip",
"NameExtraDeletedScene": "Escena eliminada",
"NameExtraFeaturette": "Minidocumental",
"NameExtraInterview": "Entrevista",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Muestra",
"NameExtraScene": "Escena",
"NameExtraShort": "Cortometraje",
"NameExtraThemeSong": "Música de presentación",
"NameExtraThemeVideo": "Video de presentación",
"NameExtraTrailer": "Tráiler",
"NameExtraUnknown": "Extra",
"Original": "Original"
}
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "Tarea de limpieza de datos del usuario",
"CleanupUserDataTaskDescription": "Limpia todos los datos del usuario (estado de visualización, favoritos, etc.) de los medios que ya no están disponibles desde hace al menos 90 días.",
"Original": "Original",
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}"
"LyricDownloadFailureFromForItem": "No se pudieron descargar las letras desde {0} para {1}",
"NameExtraBehindTheScenes": "Detrás de Cámaras",
"NameExtraClip": "Clip",
"NameExtraDeletedScene": "Escena eliminada",
"NameExtraFeaturette": "Reportaje especial",
"NameExtraInterview": "Entrevista",
"NameExtraSample": "Muestra",
"NameExtraScene": "Escena",
"NameExtraShort": "Cortometraje",
"NameExtraThemeSong": "Tema principal",
"NameExtraThemeVideo": "Vídeo del tema principal",
"NameExtraTrailer": "Tráiler",
"NameExtraUnknown": "Extra",
"NameExtraNumbered": "{0} {1}"
}
@@ -108,5 +108,18 @@
"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.",
"LyricDownloadFailureFromForItem": "No se pudo descargar las letras de {0} para {1}",
"Original": "Original"
"Original": "Original",
"NameExtraUnknown": "Extra",
"NameExtraBehindTheScenes": "Detrás de cámaras",
"NameExtraClip": "Clip",
"NameExtraDeletedScene": "Escena eliminada",
"NameExtraFeaturette": "Minidocumental",
"NameExtraInterview": "Entrevista",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Muestra",
"NameExtraScene": "Escena",
"NameExtraShort": "Cortometraje",
"NameExtraThemeSong": "Música de presentación",
"NameExtraThemeVideo": "Video de presentación",
"NameExtraTrailer": "Tráiler"
}
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "Puhasta kasutajaandmed",
"CleanupUserDataTaskDescription": "Puhastab kõik kasutajaandmed (vaatamise olek, lemmikute olek jne) meediast, mida pole enam vähemalt 90 päeva saadaval olnud.",
"LyricDownloadFailureFromForItem": "Laulusõnade hankimine teenusest {0} loole {1} nurjus",
"Original": "Algne"
"Original": "Algne",
"NameExtraBehindTheScenes": "Kulisside taga",
"NameExtraDeletedScene": "Väljajäetud stseen",
"NameExtraFeaturette": "Lisalõik",
"NameExtraInterview": "Intervjuu",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Näidis",
"NameExtraScene": "Stseen",
"NameExtraShort": "Lühifilm",
"NameExtraThemeSong": "Tunnusmeloodia",
"NameExtraThemeVideo": "Tunnusvideo",
"NameExtraTrailer": "Treiler",
"NameExtraUnknown": "Lisamaterjal",
"NameExtraClip": "Videoklipp"
}
@@ -108,5 +108,11 @@
"CleanupUserDataTaskDescription": "Gutxienez 90 egunez dagoeneko existitzen ez den multimediatik erabiltzaile-datu guztiak (ikusteko egoera, gogokoen egoera, etab.) garbitzen ditu.",
"CleanupUserDataTask": "Erabiltzaileen datuak garbitzeko zeregina",
"LyricDownloadFailureFromForItem": "Ezin izan dira {1}-ren letrak deskargatu {0}-tik",
"Original": "Jatorrizkoa"
"Original": "Jatorrizkoa",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Lagina",
"NameExtraScene": "Eszena",
"NameExtraShort": "Laburra",
"NameExtraThemeSong": "Gai-abestia",
"NameExtraThemeVideo": "Gai-bideoa"
}
@@ -1,5 +1,5 @@
{
"Artists": "Listafólk",
"Artists": "Tónlistafólk",
"Collections": "Søvn",
"Default": "Forsett",
"External": "Ytri",
@@ -29,9 +29,9 @@
"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",
"PluginInstalledWithName": "{0} innlagt",
"PluginUninstalledWithName": "{0} strikað",
"PluginUpdatedWithName": "{0} dagført",
"Shows": "Røðir",
"SubtitleDownloadFailureFromForItem": "Miseydnaðist at niðurtakað undirtekstir til {1} frá {0}",
"TvShows": "Sjónvarpsrøðir",
@@ -48,5 +48,68 @@
"TaskDownloadMissingLyricsDescription": "Niðurtekur sangtekstir",
"Movies": "Filmar",
"MixedContent": "Blandað innihald",
"Music": "Tónleikur"
"Music": "Tónleikur",
"UserStartedPlayingItemWithValues": "{0} spælur {1} á {2}",
"HeaderContinueWatching": "Hald áfram at hyggja",
"MusicVideos": "Sjónbandaløg",
"TaskUpdatePluginsDescription": "Niðurtekur og innleggur dagføringar til ískoytisforrit ið eru stillaði til at dagførast sjálvvirkandi.",
"TaskCleanTranscodeDescription": "Strikar umkotaðar fílar ið eru eldri enn 1 dag.",
"TaskOptimizeDatabase": "Albøt dátugrunn",
"NameSeasonNumber": "Sesong {0}",
"NameSeasonUnknown": "Ókend sesong",
"ScheduledTaskFailedWithName": "{0} miseydnaðist",
"Undefined": "Óskilmarkað",
"TasksMaintenanceCategory": "Viðlíkahald",
"TaskCleanLogs": "Reinsa gerðalistaskjáttu",
"UserOnlineFromDevice": "{0} er íbundin frá {1}",
"HeaderNextUp": "Næst á skránni",
"NotificationOptionPluginError": "Brek í ískoytisforriti",
"NotificationOptionInstallationFailed": "Innleggingarbrek",
"NotificationOptionServerRestartRequired": "Tørvur er á ambætaraendurbyrjan",
"TasksApplicationCategory": "Nýtsluskipan",
"NotificationOptionApplicationUpdateAvailable": "Skipanardagføring er tøk",
"NotificationOptionApplicationUpdateInstalled": "Skipanardagføring varð innløgd",
"UserStoppedPlayingItemWithValues": "{0} er liðugur at spæla {1} á {2}",
"HomeVideos": "Heimaupptøkur",
"StartupEmbyServerIsLoading": "Jellyfin-ambætarin er undir byrjanarinnlesing. Vinaliga royn aftur um eitt bil.",
"UserOfflineFromDevice": "{0} breyt av á {1}",
"UserPasswordChangedWithName": "Loyniorðið hjá brúkaranum {0} er broytt",
"TasksChannelsCategory": "Alnetsrásir",
"TaskCleanActivityLog": "Reinsa virksemisskrá",
"TaskCleanActivityLogDescription": "Strikar skrásetingar eldri enn ásetta aldur.",
"TaskCleanCache": "Reinsa kovaskjáttu",
"TaskCleanCacheDescription": "Strikar kovafílar ið kervið ikki hevur tørv á longur.",
"TaskCleanTranscode": "Reinsa umkotuskjáttu",
"TaskDownloadMissingLyrics": "Niðurtak vantandi sangtekstir",
"TaskDownloadMissingSubtitles": "Niðurtak vantandi undirtekstir",
"CleanupUserDataTaskDescription": "Strikar allar brúkaradátur, so sum spælistøðu, yndislistastøðu o.s.fr., fyri miðlar ið ikki hava verið tøkir í í minsta lagi 90 dagar.",
"CleanupUserDataTask": "Koyrsla ið reinsar brúkaradátur",
"TaskRefreshPeople": "Dagfør persónsupplýsingar",
"TaskRefreshPeopleDescription": "Dagførur metadátur um leikarar og leikstjórar í tínum margmiðlasavni.",
"TaskRefreshChannelsDescription": "Dagførur upplýsingar um alnetsrásir.",
"TaskDownloadMissingSubtitlesDescription": "Leitar á alnótini eftir vantandi undirtekstum grundað á metadátauppsetan.",
"NotificationOptionTaskFailed": "Brek undir fyriskipaðari koyrslu",
"TaskRefreshLibraryDescription": "Skannar títt miðlasavn fyri nýggjum fílum og dagførur metadátur.",
"TaskKeyframeExtractor": "Lyklamyndaúttøka",
"TaskKeyframeExtractorDescription": "Úttekur lyklamyndir frá kykmynda-fílum til tess at byggja nágreiniligari HLS-spælilistar. Koyrslan kann taka langa tíð.",
"TaskOptimizeDatabaseDescription": "Trýstur dátugrunninin saman og loysur tóma goymslu. Koyrslan kann bøta um avrikið, eftir skanning ella aðrar broytingar í savninum ið elva til dátugrunnsbroytingar.",
"TaskRefreshChapterImagesDescription": "Ger smámyndir fyri kykmyndir ið hava kapitlar.",
"TaskRefreshChapterImages": "Kapitlamyndaúttøkur",
"NotificationOptionVideoPlayback": "Kykmyndaspæl byrjað",
"NotificationOptionVideoPlaybackStopped": "Kykmyndaspæl steðgað",
"NotificationOptionAudioPlayback": "Ljóðspæl byrjað",
"NotificationOptionAudioPlaybackStopped": "Ljóðspæl steðgað",
"TaskExtractMediaSegments": "Leita eftir margmiðlabrotum",
"TaskExtractMediaSegmentsDescription": "Framleiður upplýsingar um brot í margmiðlum, við hjálp frá MediaSegment-virktum ískoytisforritum.",
"NotificationOptionCameraImageUploaded": "Ljósmynd uppsend",
"NameExtraShort": "Stuttfilmur",
"NameExtraThemeSong": "Eyðkennislag",
"NameExtraTrailer": "Forfilmur",
"NameExtraInterview": "Samrøða",
"NameExtraBehindTheScenes": "Aftanfyri leiktjøldini",
"NameExtraClip": "Klipp",
"NameExtraNumbered": "{0} {1}",
"NameExtraFeaturette": "Stuttur heimildarfilmur",
"TaskAudioNormalization": "Ljóðjavnan",
"TaskAudioNormalizationDescription": "Kannar fílur fyri dátum til ljóðjavnan."
}
@@ -108,5 +108,18 @@
"CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.",
"CleanupUserDataTask": "Tâche de nettoyage des données utilisateur",
"LyricDownloadFailureFromForItem": "Le téléchargement des paroles a échoué de {0} pour {1}",
"Original": "Original"
"Original": "Original",
"NameExtraBehindTheScenes": "Dans Les Coulisses",
"NameExtraClip": "Clip",
"NameExtraDeletedScene": "Scène supprimée",
"NameExtraFeaturette": "Court-métrage",
"NameExtraInterview": "Entrevue",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Échantillon",
"NameExtraScene": "Scène",
"NameExtraShort": "Court-métrage",
"NameExtraThemeSong": "Chanson thème",
"NameExtraThemeVideo": "Générique",
"NameExtraTrailer": "Bande-annonce",
"NameExtraUnknown": "Extra"
}
@@ -108,5 +108,18 @@
"CleanupUserDataTaskDescription": "Nettoie toutes les données utilisateur (état de la montre, statut favori, etc.) des supports qui ne sont plus présents depuis au moins 90 jours.",
"CleanupUserDataTask": "Tâche de nettoyage des données utilisateur",
"LyricDownloadFailureFromForItem": "Le téléchargement des paroles à échoué de {0} pour {1}",
"Original": "Original"
"Original": "Original",
"NameExtraBehindTheScenes": "Dans Les Coulisses",
"NameExtraClip": "Clip",
"NameExtraDeletedScene": "Scène supprimée",
"NameExtraFeaturette": "Court-métrage",
"NameExtraInterview": "Entrevue",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Échantillon",
"NameExtraScene": "Scène",
"NameExtraShort": "Court-métrage",
"NameExtraThemeSong": "Thème musical",
"NameExtraThemeVideo": "Générique",
"NameExtraTrailer": "Bande-annonce",
"NameExtraUnknown": "Extra"
}
@@ -105,5 +105,6 @@
"TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।",
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
"TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य"
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य",
"Original": "असली"
}
@@ -108,5 +108,17 @@
"CleanupUserDataTaskDescription": "Legalább 90 napja nem elérhető médiákhoz kapcsolódó összes felhasználói adat (pl. megtekintési állapot, kedvencek) törlése.",
"CleanupUserDataTask": "Felhasználói adatok tisztítása feladat",
"Original": "Eredeti",
"LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen"
"LyricDownloadFailureFromForItem": "Dalszöveg letöltése {0}-tól {1}-hez sikertelen",
"NameExtraBehindTheScenes": "Színfalak mögött",
"NameExtraClip": "Klip",
"NameExtraDeletedScene": "Törölt jelenet",
"NameExtraFeaturette": "Kísérő film",
"NameExtraInterview": "Interjú",
"NameExtraSample": "Minta",
"NameExtraScene": "Jelenet",
"NameExtraShort": "Rövidfilm",
"NameExtraThemeSong": "Főcímdal",
"NameExtraThemeVideo": "Főcímvideó",
"NameExtraTrailer": "Előzetes",
"NameExtraUnknown": "Extra"
}
@@ -62,7 +62,7 @@
"UserDownloadingItemWithValues": "{0} hleður niður {1}",
"SubtitleDownloadFailureFromForItem": "Tókst ekki að hala niður skjátextum frá {0} til {1}",
"Shows": "Þættir",
"TaskRefreshChannelsDescription": "Endurhlaða upplýsingum netrása.",
"TaskRefreshChannelsDescription": "Endurhleður upplýsingum netrása.",
"TaskRefreshChannels": "Endurhlaða Rásir",
"TaskCleanTranscodeDescription": "Eyða umkóðuðum skrám sem eru meira en einum degi eldri.",
"TaskCleanTranscode": "Hreinsa Umkóðunarmöppu",
@@ -80,7 +80,7 @@
"TasksMaintenanceCategory": "Viðhald",
"Default": "Sjálfgefið",
"TaskCleanActivityLog": "Hreinsa athafnaskrá",
"TaskRefreshPeople": "Endurnýja fólk",
"TaskRefreshPeople": "Endurnýja upplýsingar um fólk",
"TaskDownloadMissingSubtitles": "Sækja texta sem vantar",
"TaskOptimizeDatabase": "Fínstilla gagnagrunn",
"Undefined": "Óskilgreint",
@@ -95,8 +95,8 @@
"TaskCleanActivityLogDescription": "Eyðir virkniskráningarfærslum sem hafa náð settum hámarksaldri.",
"Forced": "Þvingað",
"External": "Útvær",
"TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir fyrir myndbönd í virkum söfnum.",
"TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir",
"TaskRefreshTrickplayImagesDescription": "Býr til hraðspilunarmyndir (Trickplay) fyrir myndbönd í virkum söfnum.",
"TaskRefreshTrickplayImages": "Búa til hraðspilunarmyndir (Trickplay)",
"TaskAudioNormalization": "Hljóðstöðlun",
"TaskAudioNormalizationDescription": "Leitar að hljóðstöðlunargögnum í skrám.",
"TaskDownloadMissingLyricsDescription": "Sækja söngtexta fyrir lög",
@@ -108,5 +108,18 @@
"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."
"TaskMoveTrickplayImagesDescription": "Flytur fyrirliggjandi Trickplay-skrár í samræmi við stillingar safnsins.",
"NameExtraBehindTheScenes": "Bak við tjöldin",
"NameExtraClip": "Brot",
"NameExtraDeletedScene": "Eydd atriði",
"NameExtraFeaturette": "Stutt heimildarmynd",
"NameExtraInterview": "Viðtal",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Sýnishorn",
"NameExtraScene": "Sena",
"NameExtraShort": "Stuttmynd",
"NameExtraThemeSong": "Þema lag",
"NameExtraThemeVideo": "Þema myndband",
"NameExtraTrailer": "Stikla",
"NameExtraUnknown": "Aukaefni"
}
@@ -108,5 +108,15 @@
"CleanupUserDataTask": "Task di pulizia dei dati utente",
"CleanupUserDataTaskDescription": "Pulisce tutti i dati utente (stato di visione, status preferiti, ecc.) dai contenuti non più presenti da almeno 90 giorni.",
"Original": "Originale",
"LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}"
"LyricDownloadFailureFromForItem": "Scaricamento dei testi non riuscito da {0} per {1}",
"NameExtraBehindTheScenes": "Dietro le scene",
"NameExtraClip": "Filmato",
"NameExtraDeletedScene": "Scena eliminata",
"NameExtraInterview": "Intervista",
"NameExtraNumbered": "{0} {1}",
"NameExtraScene": "Scena",
"NameExtraSample": "Campione",
"NameExtraShort": "Corto",
"NameExtraThemeSong": "Sigla musicale",
"NameExtraTrailer": "Trailer"
}
@@ -108,5 +108,8 @@
"CleanupUserDataTask": "사용자 데이터 정리 작업",
"CleanupUserDataTaskDescription": "최소 90일 이상 존재하지 않는 미디어에 대한 사용자 데이터(시청 상태, 즐겨찾기 등)를 정리합니다.",
"LyricDownloadFailureFromForItem": "{1}에 대한 가사를 {0}에서 다운로드하지 못했습니다",
"Original": "원본"
"Original": "원본",
"NameExtraClip": "클립",
"NameExtraDeletedScene": "삭제된 장면",
"NameExtraInterview": "인터뷰"
}
@@ -104,5 +104,9 @@
"TaskKeyframeExtractorDescription": "Extrahéiert Schlësselbiller aus Videodateien, fir méi präzis HLS-Playlisten ze erstellen. Dës Aufgab kann eng längere Zäit daueren.",
"TaskRefreshChannelsDescription": "Aktualiséiert Informatiounen iwwer Internetkanäl.",
"TaskExtractMediaSegmentsDescription": "Extrahéiert oder kritt Mediesegmenter aus Plugins, déi MediaSegment ënnerstëtzen.",
"TaskOptimizeDatabaseDescription": "Kompriméiert dDatebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann dPerformance verbesseren."
"TaskOptimizeDatabaseDescription": "Kompriméiert dDatebank a schneit de fräie Speicherplatz zou. Dës Aufgab no engem Bibliothéik-Scan oder anere Ännerungen, déi Datebankmodifikatioune mat sech bréngen, auszeféieren, kann dPerformance verbesseren.",
"LyricDownloadFailureFromForItem": "Feeler beim Download vun de Songtexter vun {0} fir {1}",
"Original": "Original",
"CleanupUserDataTask": "Aufgab fir Berengege vu Benotzerdaten",
"CleanupUserDataTaskDescription": "Läscht all Benotzerdaten (Ofspillstatus, Favoritestatus, asw.) vu Medien, déi zënter mindestens 90 Deeg net méi besteeënd sinn."
}
@@ -3,15 +3,15 @@
"Artists": "Atlikėjai",
"AuthenticationSucceededWithUserName": "{0} sėkmingai autentifikuota",
"Books": "Knygos",
"ChapterNameValue": "Scena{0}",
"Collections": "Rinkiniai",
"ChapterNameValue": "Skyrius{0}",
"Collections": "Kolekcijos",
"FailedLoginAttemptWithUserName": "Nesėkmingas {0} bandymas prisijungti",
"Favorites": "Mėgstami",
"Folders": "Katalogai",
"Folders": "Aplankai",
"Genres": "Žanrai",
"HeaderContinueWatching": "Žiūrėti toliau",
"HeaderFavoriteEpisodes": "Mėgstamiausios serijos",
"HeaderFavoriteShows": "Mėgstamiausios TV Laidos",
"HeaderFavoriteEpisodes": "Mėgstami Epizodai",
"HeaderFavoriteShows": "Mėgstamos TV Laidos",
"HeaderLiveTV": "Tiesioginė TV",
"HeaderNextUp": "Toliau",
"HomeVideos": "Namų vaizdo įrašai",
@@ -26,14 +26,14 @@
"NameInstallFailed": "{0} diegimo klaida",
"NameSeasonNumber": "Sezonas {0}",
"NameSeasonUnknown": "Sezonas neatpažintas",
"NewVersionIsAvailable": "Nauja \"Jellyfin Server\" versija yra prieinama atsisiuntimui.",
"NewVersionIsAvailable": "Nauja Jellyfin Server versija yra prieinama atsisiuntimui.",
"NotificationOptionApplicationUpdateAvailable": "Galimi programos atnaujinimai",
"NotificationOptionApplicationUpdateInstalled": "Programos atnaujinimai įdiegti",
"NotificationOptionAudioPlayback": "Garso atkūrimas pradėtas",
"NotificationOptionAudioPlaybackStopped": "Garso atkūrimas sustabdytas",
"NotificationOptionCameraImageUploaded": "Kameros vaizdai įkelti",
"NotificationOptionCameraImageUploaded": "Kameros atvaizdai įkelti",
"NotificationOptionInstallationFailed": "Diegimo klaida",
"NotificationOptionNewLibraryContent": "Naujas turinys įkeltas",
"NotificationOptionNewLibraryContent": "Pridėtas naujas turinys",
"NotificationOptionPluginError": "Įskiepio klaida",
"NotificationOptionPluginInstalled": "Įskiepis įdiegtas",
"NotificationOptionPluginUninstalled": "Įskiepis išdiegtas",
@@ -51,7 +51,7 @@
"Shows": "Laidos",
"StartupEmbyServerIsLoading": "Jellyfin Server kraunasi. Netrukus pabandykite dar kartą.",
"SubtitleDownloadFailureFromForItem": "{1} subtitrai buvo nesėkmingai parsiųsti iš {0}",
"TvShows": "TV laidos",
"TvShows": "TV Laidos",
"UserCreatedWithName": "Buvo sukurtas {0} naudotojas",
"UserDeletedWithName": "Naudotojas {0} ištrintas",
"UserDownloadingItemWithValues": "{0} siunčiasi {1}",
@@ -65,8 +65,8 @@
"TaskUpdatePluginsDescription": "Atsisiunčia ir įdiegia įskiepių, kurie sukonfigūruoti atnaujinti automatiškai, naujinius.",
"TaskUpdatePlugins": "Atnaujinti įskieius",
"TaskDownloadMissingSubtitlesDescription": "Ieško trūkstamų subtitrų internete remiantis metaduomenų konfigūracija.",
"TaskCleanTranscodeDescription": "Ištrina dienos senumo perkodavimo failus.",
"TaskCleanTranscode": "Išvalyti perkodavimo katalogą",
"TaskCleanTranscodeDescription": "Ištrina dienos senumo transkodavimo failus.",
"TaskCleanTranscode": "Išvalyti transkodavimo katalogą",
"TaskRefreshLibraryDescription": "Skenuoja medijos biblioteką, ieškodamas naujų failų, ir atnaujina metaduomenis.",
"TaskRefreshLibrary": "Skenuoti medijos biblioteką",
"TaskDownloadMissingSubtitles": "Atsisiųsti trūkstamus subtitrus",
@@ -76,8 +76,8 @@
"TaskRefreshPeople": "Atnaujinti žmones",
"TaskCleanLogsDescription": "Ištrina žurnalo failus kurie yra senesni nei {0} dienos.",
"TaskCleanLogs": "Išvalyti žurnalą",
"TaskRefreshChapterImagesDescription": "Sukuria vaizdo įrašų, kuriuose yra skyrių, miniatiūras.",
"TaskRefreshChapterImages": "Ištraukti skyrių vaizdus",
"TaskRefreshChapterImagesDescription": "Sukuria miniatiūras vaizdo įrašams, kuriuose yra skyriai.",
"TaskRefreshChapterImages": "Ištraukti skyrių atvaizdus",
"TaskCleanCache": "Išvalyti talpyklą",
"TaskCleanCacheDescription": "Ištrina talpyklos failus, kurių daugiau nereikia sistemai.",
"TasksChannelsCategory": "Internetiniai kanalai",
@@ -96,15 +96,30 @@
"External": "Išorinis",
"HearingImpaired": "Su klausos sutrikimais",
"TaskRefreshTrickplayImages": "Generuoti Trickplay atvaizdus",
"TaskRefreshTrickplayImagesDescription": "Sukuria trickplay peržiūras vaizdo įrašams įgalintose bibliotekose.",
"TaskRefreshTrickplayImagesDescription": "Sukuria vaizdo įrašų, esančių įgalintose bibliotekose, Trickplay peržiūras.",
"TaskAudioNormalization": "Garso normalizavimas",
"TaskAudioNormalizationDescription": "Skenuoja failus, ieškant garso normalizavimo duomenų.",
"TaskExtractMediaSegments": "Medijos segmentų nuskaitymas",
"TaskDownloadMissingLyrics": "Parsisiųsti trūkstamus dainų tekstus",
"TaskExtractMediaSegmentsDescription": "Ištraukia arba gauna medijos segmentus iš MediaSegment ijungtų įskiepių.",
"TaskMoveTrickplayImages": "Pakeisti Trickplay vaizdų vietą",
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius trickplay failus pagal bibliotekos nustatymus.",
"TaskMoveTrickplayImages": "Pakeisti Trickplay atvaizdų vietą",
"TaskMoveTrickplayImagesDescription": "Perkelia egzistuojančius Trickplay failus pagal bibliotekos nustatymus.",
"TaskDownloadMissingLyricsDescription": "Parsisiųsti dainų žodžius",
"CleanupUserDataTask": "Naudotojo duomenų valymo užduotis",
"CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamiausią būseną ir t. t.)."
"CleanupUserDataTaskDescription": "Iš medijos, kurios nebėra bent 90 dienų, išvalo visus naudotojo duomenis (žiūrėjimo būseną, mėgstamą būseną ir t. t.).",
"LyricDownloadFailureFromForItem": "Nepavyko atsisiųsti dainos žodžių iš {0}, skirto {1}",
"NameExtraBehindTheScenes": "Užkulisiuose",
"NameExtraClip": "Klipas",
"NameExtraDeletedScene": "Ištrinta scena",
"NameExtraInterview": "Interviu",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Pavyzdys",
"NameExtraScene": "Scena",
"NameExtraThemeSong": "Teminė daina",
"NameExtraThemeVideo": "Teminis vaizdo įrašas",
"NameExtraTrailer": "Anonsas",
"NameExtraUnknown": "Papildomas",
"Original": "Originalus",
"NameExtraFeaturette": "Trumpametražis filmas",
"NameExtraShort": "Trumpas filmukas"
}
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "Opruimtaak gebruikersdata",
"Genres": "Genres",
"Original": "Oorspronkelijk",
"LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt"
"LyricDownloadFailureFromForItem": "Downloaden van liedteksten voor {1} van {0} mislukt",
"NameExtraBehindTheScenes": "Achter de schermen",
"NameExtraClip": "Clip",
"NameExtraDeletedScene": "Geschrapte scène",
"NameExtraFeaturette": "Featurette",
"NameExtraInterview": "Vraaggesprek",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Voorbeeldfragment",
"NameExtraScene": "Scène",
"NameExtraShort": "Korte film",
"NameExtraThemeSong": "Themamuziek",
"NameExtraThemeVideo": "Themavideo",
"NameExtraTrailer": "Trailer",
"NameExtraUnknown": "Extra inhoud"
}
@@ -1,3 +1,27 @@
{
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}"
"AppDeviceValues": "Aplicacion: {0}, Periferic: {1}",
"Books": "Libres",
"Artists": "Artistas",
"Collections": "Collecciones",
"ChapterNameValue": "Capitol {0}",
"External": "Extèrn",
"Folders": "Dorsièrs",
"Favorites": "Favorits",
"HeaderContinueWatching": "Contunhar de regardar",
"HeaderFavoriteEpisodes": "Episòdis Favorits",
"AuthenticationSucceededWithUserName": "{0} autentificat amb succès",
"HeaderFavoriteShows": "Serias Favoritas",
"HeaderLiveTV": "TV en dirècte",
"HeaderNextUp": "Seguent",
"HearingImpaired": "Amb de deficiéncias auditivas",
"Movies": "Filmes",
"Music": "Musica",
"Latest": "Darrièr",
"Forced": "Forçat",
"Default": "Defaut",
"Genres": "Genres",
"HomeVideos": "Vidèos d'Acuèlh",
"Inherit": "Eiretar",
"LabelIpAddressValue": "Adreça IP: {0}",
"LabelRunningTimeValue": "Temps d'execucion : {0}"
}
@@ -108,5 +108,18 @@
"CleanupUserDataTaskDescription": "Usuwa wszystkie dane użytkownika (stan oglądanych, status ulubionych itp.) z mediów, które nie są dostępne od co najmniej 90 dni.",
"CleanupUserDataTask": "Zadanie czyszczenia danych użytkownika",
"Original": "Oryginalny",
"LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}"
"LyricDownloadFailureFromForItem": "Błąd podczas pobierania tekstu piosenki z {0} dla {1}",
"NameExtraBehindTheScenes": "Za kulisami",
"NameExtraClip": "Urywek",
"NameExtraDeletedScene": "Usunięta scena",
"NameExtraFeaturette": "Film średniometrażowy",
"NameExtraInterview": "Wywiad",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Fragment",
"NameExtraScene": "Scena",
"NameExtraShort": "Film krótkometrażowy",
"NameExtraThemeSong": "Czołówka",
"NameExtraThemeVideo": "Wideo wprowadzające",
"NameExtraTrailer": "Zwiastun",
"NameExtraUnknown": "Dodatek"
}
@@ -108,5 +108,17 @@
"CleanupUserDataTask": "Tarefa de limpeza de dados do usuário",
"CleanupUserDataTaskDescription": "Limpa todos os dados do usuário (estado de visualização, status de favorito, etc.) de mídias que não estão presentes por pelo menos 90 dias.",
"LyricDownloadFailureFromForItem": "Download das Letras falharam em {0} para o item {1}",
"Original": "Original"
"Original": "Original",
"NameExtraBehindTheScenes": "Nos Bastidores",
"NameExtraClip": "Clipe",
"NameExtraDeletedScene": "cena Extra",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Trecho",
"NameExtraScene": "Cena",
"NameExtraShort": "Curta-metragem",
"NameExtraThemeSong": "Música Tema",
"NameExtraThemeVideo": "Vídeo de Abertura",
"NameExtraTrailer": "Trailer",
"NameExtraUnknown": "Extra",
"NameExtraFeaturette": "Nos Bastidores"
}
@@ -95,18 +95,30 @@
"HearingImpaired": "Problemas auditivos",
"TaskKeyframeExtractor": "Extrator de quadro-chave",
"TaskKeyframeExtractorDescription": "Retira frames chave do video para criar listas HLS precisas. Esta tarefa pode correr durante algum tempo.",
"TaskRefreshTrickplayImages": "Gerar imagens de trickplay",
"TaskRefreshTrickplayImagesDescription": "Cria pré-visualizações de trickplay para vídeos nas bibliotecas ativadas.",
"TaskRefreshTrickplayImages": "Gerar imagens de Trickplay",
"TaskRefreshTrickplayImagesDescription": "Cria miniaturas de pré-visualização (Trickplay) para vídeos nas bibliotecas ativadas.",
"TaskAudioNormalizationDescription": "Analisa os ficheiros para obter dados de normalização de áudio.",
"TaskAudioNormalization": "Normalização de áudio",
"TaskDownloadMissingLyrics": "Transferir letra em falta",
"TaskDownloadMissingLyricsDescription": "Transferir letra para músicas",
"TaskMoveTrickplayImagesDescription": "Move os ficheiros trickplay existentes de acordo com as definições da mediateca.",
"TaskMoveTrickplayImagesDescription": "Move os ficheiros Trickplay existentes de acordo com as definições da mediateca.",
"TaskExtractMediaSegments": "Analisar segmentos de multimédia",
"TaskExtractMediaSegmentsDescription": "Extrai ou obtém segmentos de multimédia a partir de plugins com suporte para MediaSegment.",
"TaskMoveTrickplayImages": "Migrar a localização da imagem do Trickplay",
"TaskMoveTrickplayImages": "Migrar a localização das imagens de Trickplay",
"CleanupUserDataTask": "Task de limpeza de dados do usuário",
"CleanupUserDataTaskDescription": "Remove todos os dados do usuário (progresso, favoritos etc) de mídias que não estão presentes há pelo menos 90 dias.",
"Original": "Original",
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}"
"LyricDownloadFailureFromForItem": "Erro ao descarregar letras de {0} para {1}",
"NameExtraBehindTheScenes": "Bastidores",
"NameExtraClip": "Clip",
"NameExtraDeletedScene": "Cena Eliminada",
"NameExtraFeaturette": "Média-metragem",
"NameExtraInterview": "Entrevista",
"NameExtraSample": "Amostra",
"NameExtraShort": "Curta-metragem",
"NameExtraThemeSong": "Tema Principal",
"NameExtraThemeVideo": "Vídeo de Abertura",
"NameExtraScene": "Cena",
"NameExtraUnknown": "Extra",
"NameExtraTrailer": "Trailer"
}
@@ -108,5 +108,16 @@
"CleanupUserDataTask": "Задача очистки пользовательских данных",
"CleanupUserDataTaskDescription": "Очищает все пользовательские данные (состояние просмотра, статус избранного и т.д.) с медиа, отсутствующих по меньшей мере в течение 90 дней.",
"Original": "Оригинальный",
"LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}"
"LyricDownloadFailureFromForItem": "Не получилось скачать текст песни с {0} для {1}",
"NameExtraBehindTheScenes": "За кулисами",
"NameExtraClip": "Отрывок",
"NameExtraDeletedScene": "Удалённая сцена",
"NameExtraFeaturette": "Короткометражка",
"NameExtraInterview": "Интервью",
"NameExtraSample": "Образец",
"NameExtraScene": "Сцена",
"NameExtraThemeSong": "Заглавная песня",
"NameExtraThemeVideo": "Заглавное видео",
"NameExtraTrailer": "Трейлер",
"NameExtraUnknown": "Дополнительный материал"
}
@@ -108,5 +108,18 @@
"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í.",
"LyricDownloadFailureFromForItem": "Text piesne sa nepodarilo stiahnuť z {0} pre {1}",
"Original": "Originál"
"Original": "Originál",
"NameExtraBehindTheScenes": "Zo zákulisia",
"NameExtraClip": "Klip",
"NameExtraDeletedScene": "Vystrihnutá scéna",
"NameExtraFeaturette": "Bonus",
"NameExtraInterview": "Rozhovor",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Ukážka",
"NameExtraScene": "Scéna",
"NameExtraShort": "Krátky film",
"NameExtraThemeSong": "Úvodná pieseň",
"NameExtraThemeVideo": "Úvodné video",
"NameExtraTrailer": "Trailer",
"NameExtraUnknown": "Extra"
}
@@ -108,5 +108,13 @@
"CleanupUserDataTaskDescription": "Tar bort all användardata (såsom vad du sett, favoriter med mera) för media som inte funnits på enheten på minst 90 dagar.",
"CleanupUserDataTask": "Uppgift för rensning av användardata",
"Original": "Original",
"LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}"
"LyricDownloadFailureFromForItem": "Misslyckades att ladda ner låttexter från {0} för {1}",
"NameExtraBehindTheScenes": "Bakom kulisserna",
"NameExtraDeletedScene": "Borttagen scen",
"NameExtraInterview": "Intervju",
"NameExtraNumbered": "{0} {1}",
"NameExtraScene": "Scen",
"NameExtraShort": "Kortfilm",
"NameExtraThemeSong": "Signaturmelodi",
"NameExtraTrailer": "Trailer"
}
@@ -106,5 +106,7 @@
"TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment",
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
"CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน"
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน",
"LyricDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1}",
"Original": "ต้นฉบับ"
}
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "Kullanıcı verisi temizleme görevi",
"CleanupUserDataTaskDescription": "En az 90 gün boyunca artık mevcut olmayan medyadaki tüm kullanıcı verilerini (İzleme durumu, favori durumu vb.) temizler.",
"LyricDownloadFailureFromForItem": "{1} şarkı sözleri {0} adresinden indirilemedi",
"Original": "Orijinal"
"Original": "Orijinal",
"NameExtraBehindTheScenes": "Kamera Arkası",
"NameExtraClip": "Klip",
"NameExtraDeletedScene": "Silinmiş Sahne",
"NameExtraFeaturette": "Kısa film",
"NameExtraInterview": "Röportaj",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Örnek",
"NameExtraScene": "Sahne",
"NameExtraShort": "Kısa",
"NameExtraThemeSong": "Tanıtım Müziği",
"NameExtraThemeVideo": "Tanıtım Videosu",
"NameExtraTrailer": "Fragman",
"NameExtraUnknown": "Fazladan"
}
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "Завдання очищення даних користувача",
"CleanupUserDataTaskDescription": "Очищає всі дані користувача (стан перегляду, статус обраного тощо) з медіа, які перестали бути доступними щонайменше 90 днів тому.",
"Original": "Оригінал",
"LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}"
"LyricDownloadFailureFromForItem": "Не вдалося завантажити текст пісні з {0} для {1}",
"NameExtraBehindTheScenes": "За лаштунками",
"NameExtraClip": "Кліп",
"NameExtraDeletedScene": "Видалена сцена",
"NameExtraFeaturette": "Фічуретка",
"NameExtraInterview": "Інтерв’ю",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Приклад",
"NameExtraScene": "Сцена",
"NameExtraShort": "Коротко",
"NameExtraThemeSong": "Тематична пісня",
"NameExtraThemeVideo": "Тематичне вiдео",
"NameExtraTrailer": "Трейлер",
"NameExtraUnknown": "Додатково"
}
@@ -32,7 +32,7 @@
"TasksLibraryCategory": "Thư Viện",
"TasksMaintenanceCategory": "Bảo Trì",
"VersionNumber": "Phiên Bản {0}",
"UserStoppedPlayingItemWithValues": "{0} đã kết thúc phát {1} trên {2}",
"UserStoppedPlayingItemWithValues": "{0} đã phát xong {1} trên {2}",
"UserStartedPlayingItemWithValues": "{0} đang phát {1} trên {2}",
"UserPasswordChangedWithName": "Mật khẩu đã được thay đổi cho người dùng {0}",
"UserOnlineFromDevice": "{0} trực tuyến từ {1}",
@@ -79,7 +79,7 @@
"HeaderNextUp": "Tiếp Theo",
"HeaderFavoriteShows": "Chương Trình Yêu Thích",
"HeaderFavoriteEpisodes": "Tập Phim Yêu Thích",
"FailedLoginAttemptWithUserName": "Nỗ lực đăng nhập không thành công từ {0}",
"FailedLoginAttemptWithUserName": "Cố gắng đăng nhập thất bại từ {0}",
"ChapterNameValue": "Phân Cảnh {0}",
"Books": "Sách",
"AuthenticationSucceededWithUserName": "{0} xác thực thành công",
@@ -95,18 +95,31 @@
"TaskKeyframeExtractorDescription": "Trích xuất khung hình chính từ các tệp video để tạo danh sách phát HLS chính xác hơn. Tác vụ này có thể chạy trong một thời gian dài.",
"External": "Bên ngoài",
"HearingImpaired": "Khiếm Thính",
"TaskRefreshTrickplayImages": "Tạo Ảnh Xem Trước Trickplay",
"TaskRefreshTrickplayImagesDescription": "Tạo bản xem trước trịckplay cho video trong thư viện đã bật.",
"TaskRefreshTrickplayImages": "Tạo Ảnh Tua Nhanh (Trickplay)",
"TaskRefreshTrickplayImagesDescription": "Tạo ảnh tua nhanh (trịckplay) xem thử cho các video trong các thư viện được kích hoạt.",
"TaskAudioNormalization": "Chuẩn Hóa Âm Thanh",
"TaskAudioNormalizationDescription": "Quét tập tin để tìm dữ liệu chuẩn hóa âm thanh.",
"TaskDownloadMissingLyricsDescription": "Tải xuống lời cho bài hát",
"TaskDownloadMissingLyrics": "Tải xuống lời bị thiếu",
"TaskExtractMediaSegmentsDescription": "Trích xuất hoặc lấy các phân đoạn phương tiện từ các plugin hỗ trợ MediaSegment.",
"TaskMoveTrickplayImages": "Di chuyển vtnh nh Trickplay",
"TaskMoveTrickplayImagesDescription": "Di chuyển các tp tin trickplay hiện có theo cài đặt thư viện.",
"TaskMoveTrickplayImages": "Di Chuyển VTnh Tua Nhanh (Trickplay)",
"TaskMoveTrickplayImagesDescription": "Di chuyển các tp ảnh tua nhanh (trickplay) hiện có theo cài đặt thư viện.",
"TaskExtractMediaSegments": "Quét Phân Đoạn Phương Tiện",
"CleanupUserDataTask": "Tác vụ dọn dẹp dữ liệu người dùng",
"CleanupUserDataTaskDescription": "Làm sạch tất cả dữ liệu người dùng (trạng thái xem, trạng thái yêu thích, v.v.) từ phương tiện không còn có mặt trong ít nhất 90 ngày.",
"Original": "Gốc",
"LyricDownloadFailureFromForItem": "Lời bài hát không tải xuống được từ {0} cho {1}"
"LyricDownloadFailureFromForItem": "Lời bài hát không tải xuống được từ {0} cho {1}",
"NameExtraBehindTheScenes": "Hậu Trường",
"NameExtraDeletedScene": "Cảnh Bị Xóa",
"NameExtraInterview": "Phỏng vấn",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "Mẫu",
"NameExtraScene": "Cảnh",
"NameExtraShort": "Ngắn",
"NameExtraThemeSong": "Bài Hát Chủ Đề",
"NameExtraThemeVideo": "Video Chủ Đề",
"NameExtraFeaturette": "Nội dung phụ",
"NameExtraClip": "Clip ngắn",
"NameExtraTrailer": "Đoạn giới thiệu",
"NameExtraUnknown": "Nội dung bổ sung"
}
@@ -108,5 +108,18 @@
"CleanupUserDataTask": "用户数据清理任务",
"CleanupUserDataTaskDescription": "清理已被删除超过90天的媒体中的所有用户数据(观看状态、收藏夹状态等)。",
"LyricDownloadFailureFromForItem": "无法从 {0} 下载 {1} 的歌词",
"Original": "原始"
"Original": "原始",
"NameExtraBehindTheScenes": "幕后花絮",
"NameExtraClip": "片段",
"NameExtraDeletedScene": "删减场景",
"NameExtraFeaturette": "花絮",
"NameExtraInterview": "采访",
"NameExtraNumbered": "{0} {1}",
"NameExtraSample": "样本",
"NameExtraScene": "场景",
"NameExtraShort": "短片",
"NameExtraThemeSong": "主题曲",
"NameExtraThemeVideo": "主题视频",
"NameExtraTrailer": "预告片",
"NameExtraUnknown": "额外"
}
@@ -219,28 +219,15 @@ namespace Emby.Server.Implementations.Playlists
var playlist = _libraryManager.GetItemById(playlistId) as Playlist
?? throw new ArgumentException("No Playlist exists with Id " + playlistId);
// Retrieve all the items to be added to the playlist
// Retrieve all the items to be added to the playlist.
var newItems = GetPlaylistItems(newItemIds, user, options)
.Where(i => i.SupportsAddingToPlaylist);
// Filter out duplicate items
var existingIds = playlist.LinkedChildren.Select(c => c.ItemId).ToHashSet();
newItems = newItems
.Where(i => !existingIds.Contains(i.Id))
.Distinct();
// Create a list of the new linked children to add to the playlist
var childrenToAdd = newItems
.Select(LinkedChild.Create)
.ToList();
// Log duplicates that have been ignored, if any
int numDuplicates = newItemIds.Count - childrenToAdd.Count;
if (numDuplicates > 0)
{
_logger.LogWarning("Ignored adding {DuplicateCount} duplicate items to playlist {PlaylistName}.", numDuplicates, playlist.Name);
}
// Do nothing else if there are no items to add to the playlist
if (childrenToAdd.Count == 0)
{
@@ -255,6 +255,14 @@ namespace Emby.Server.Implementations.Plugins
}
_plugins.Add(plugin);
// Updating a disabled plugin must not enable it again.
if (plugin.Manifest.Status == PluginStatus.Disabled)
{
ProcessAlternative(plugin);
return;
}
EnablePlugin(plugin);
}
@@ -632,9 +640,10 @@ namespace Emby.Server.Implementations.Plugins
return;
}
var predecessor = _plugins.OrderByDescending(p => p.Version)
.FirstOrDefault(p => p.Id.Equals(plugin.Id) && p.IsEnabledAndSupported && p.Version != plugin.Version);
if (predecessor is not null)
var successor = _plugins.FirstOrDefault(p => p.Id.Equals(plugin.Id)
&& p.Version > plugin.Version
&& (p.IsEnabledAndSupported || p.Manifest.Status == PluginStatus.Disabled));
if (successor is not null)
{
return;
}
@@ -763,6 +772,8 @@ namespace Emby.Server.Implementations.Plugins
var entry = versions[x];
if (!string.Equals(lastName, entry.Name, StringComparison.OrdinalIgnoreCase))
{
lastName = string.Empty;
if (!TryGetPluginDlls(entry, out var allowedDlls))
{
_logger.LogError("One or more assembly paths was invalid. Marking plugin {Plugin} as \"Malfunctioned\".", entry.Name);
@@ -772,15 +783,18 @@ namespace Emby.Server.Implementations.Plugins
entry.DllFiles = allowedDlls;
// Only clean up older versions when this version will actually be loaded.
if (entry.IsEnabledAndSupported)
{
lastName = entry.Name;
continue;
}
continue;
}
if (string.IsNullOrEmpty(lastName))
{
// Unnamed plugin, so there is nothing to match older versions against.
continue;
}
@@ -891,9 +905,9 @@ namespace Emby.Server.Implementations.Plugins
if (previousVersion is null)
{
// This value is memory only - so that the web will show restart required.
plugin.Manifest.Status = PluginStatus.Restart;
plugin.Manifest.AutoUpdate = false;
// Memory only, so that the web will show restart required. The manifest must keep
// holding the persisted state, or a later save would write the wrong state to disk.
plugin.RestartRequired = true;
return;
}
@@ -906,9 +920,7 @@ namespace Emby.Server.Implementations.Plugins
_logger.LogError("Unable to supercede version {Version} of {Name}", previousVersion.Version, previousVersion.Name);
}
// This value is memory only - so that the web will show restart required.
plugin.Manifest.Status = PluginStatus.Restart;
plugin.Manifest.AutoUpdate = false;
plugin.RestartRequired = true;
}
}
}
@@ -641,8 +641,7 @@ namespace Emby.Server.Implementations.Session
if (playingSessions.Count > 0)
{
var idle = playingSessions
.Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5)
.ToList();
.Where(i => (DateTime.UtcNow - i.LastPlaybackCheckIn).TotalMinutes > 5);
foreach (var session in idle)
{
@@ -223,7 +223,7 @@ namespace Emby.Server.Implementations.Session
if (inactive.Count > 0)
{
_logger.LogInformation("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
_logger.LogDebug("Sending ForceKeepAlive message to {0} inactive WebSockets.", inactive.Count);
}
foreach (var webSocket in inactive)
@@ -3,34 +3,14 @@
using System;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Sorting;
using MediaBrowser.Model.Querying;
namespace Emby.Server.Implementations.Sorting
{
public class DateLastMediaAddedComparer : IUserBaseItemComparer
public class DateLastMediaAddedComparer : IBaseItemComparer
{
/// <summary>
/// Gets or sets the user.
/// </summary>
/// <value>The user.</value>
public User User { get; set; }
/// <summary>
/// Gets or sets the user manager.
/// </summary>
/// <value>The user manager.</value>
public IUserManager UserManager { get; set; }
/// <summary>
/// Gets or sets the user data manager.
/// </summary>
/// <value>The user data manager.</value>
public IUserDataManager UserDataManager { get; set; }
/// <summary>
/// Gets the name.
/// </summary>
@@ -45,7 +45,7 @@ namespace Emby.Server.Implementations.Sorting
return x.PremiereDate.Value;
}
if (x.ProductionYear.HasValue)
if (x.ProductionYear is not null)
{
try
{
@@ -39,7 +39,7 @@ namespace Emby.Server.Implementations.Sorting
return 0;
}
if (x.ProductionYear.HasValue)
if (x.ProductionYear is not null)
{
return x.ProductionYear.Value;
}
@@ -500,8 +500,9 @@ namespace Emby.Server.Implementations.Updates
var plugins = _pluginManager.Plugins;
foreach (var plugin in plugins)
{
// Don't auto update when plugin marked not to, or when it's disabled.
if (plugin.Manifest?.AutoUpdate == false || plugin.Manifest?.Status == PluginStatus.Disabled)
// Don't auto update when plugin marked not to, or when it's disabled or pending removal.
if (plugin.Manifest?.AutoUpdate == false
|| plugin.Manifest?.Status is PluginStatus.Disabled or PluginStatus.Deleted)
{
continue;
}
@@ -1456,22 +1456,16 @@ public class DynamicHlsController : BaseJellyfinApiController
var segmentExtension = EncodingHelper.GetSegmentFileExtension(state.Request.SegmentContainer);
TranscodingJob? job;
if (System.IO.File.Exists(segmentPath))
{
job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
_logger.LogDebug("returning {0} [it exists, try 1]", segmentPath);
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
}
// Keep segment selection and transcoding replacement under the same playlist lock.
// An out-of-order request must not replace a job while another request is using its output.
using (await _transcodeManager.LockAsync(playlistPath, cancellationToken).ConfigureAwait(false))
{
TranscodingJob? job;
var startTranscoding = false;
if (System.IO.File.Exists(segmentPath))
{
job = _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
_logger.LogDebug("returning {0} [it exists, try 2]", segmentPath);
_logger.LogDebug("returning {0} [it exists]", segmentPath);
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
}
@@ -1505,6 +1499,9 @@ public class DynamicHlsController : BaseJellyfinApiController
// If the playlist doesn't already exist, startup ffmpeg
try
{
var currentJob = _transcodeManager.GetTranscodingJob(playlistPath, TranscodingJobType);
await WaitForActiveTranscodingRequests(currentJob, cancellationToken).ConfigureAwait(false);
await _transcodeManager.KillTranscodingJobs(streamingRequest.DeviceId, streamingRequest.PlaySessionId, p => false)
.ConfigureAwait(false);
@@ -1540,11 +1537,19 @@ public class DynamicHlsController : BaseJellyfinApiController
await job.TranscodingThrottler.UnpauseTranscoding().ConfigureAwait(false);
}
}
}
_logger.LogDebug("returning {0} [general case]", segmentPath);
job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
_logger.LogDebug("returning {0} [general case]", segmentPath);
job ??= _transcodeManager.OnTranscodeBeginRequest(playlistPath, TranscodingJobType);
return await GetSegmentResult(state, playlistPath, segmentPath, segmentExtension, segmentId, job, cancellationToken).ConfigureAwait(false);
}
}
internal static async Task WaitForActiveTranscodingRequests(TranscodingJob? job, CancellationToken cancellationToken)
{
while (job?.ActiveRequestCount > 0)
{
await Task.Delay(100, cancellationToken).ConfigureAwait(false);
}
}
private static double[] GetSegmentLengths(StreamState state)
@@ -1607,8 +1612,9 @@ public class DynamicHlsController : BaseJellyfinApiController
if (state.VideoStream is not null && state.IsOutputVideo)
{
// fMP4 needs this flag to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont";
// fMP4 needs frag_discont to write the audio packet DTS/PTS including the initial delay into MOOF::TRAF::TFDT
// HLS does not use SIDX, and skipping it avoids FFmpeg rewriting open-GOP boundary packet PTS
hlsArguments += $" {(useLegacySegmentOption ? "-hls_ts_options" : "-hls_segment_options")} movflags=+frag_discont+skip_sidx";
}
segmentFormat = "fmp4" + outputFmp4HeaderArg;
@@ -84,7 +84,7 @@ public class MediaInfoController : BaseJellyfinApiController
return NotFound();
}
return await _mediaInfoHelper.GetPlaybackInfo(item, user).ConfigureAwait(false);
return await _mediaInfoHelper.GetPlaybackInfo(item, user, Request).ConfigureAwait(false);
}
/// <summary>
@@ -177,6 +177,7 @@ public class MediaInfoController : BaseJellyfinApiController
var info = await _mediaInfoHelper.GetPlaybackInfo(
item,
user,
Request,
mediaSourceId,
liveStreamId)
.ConfigureAwait(false);
@@ -4,6 +4,7 @@ using System.Linq;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
using Jellyfin.Data;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Extensions;
using MediaBrowser.Controller.Dto;
@@ -103,6 +104,7 @@ public class PersonsController : BaseJellyfinApiController
personTypes,
excludePersonTypes)
{
AccessFilter = BuildAccessFilter(user),
NameContains = searchTerm,
NameStartsWith = nameStartsWith,
NameLessThan = nameLessThan,
@@ -123,6 +125,20 @@ public class PersonsController : BaseJellyfinApiController
.ToArray());
}
// People are not owned by a library, so nothing in the Peoples table says which of them a user is
// allowed to see; that only follows from the items they are credited on.
private InternalItemsQuery? BuildAccessFilter(User? user)
{
if (user is null || !user.HasContentRestrictions())
{
return null;
}
var accessFilter = new InternalItemsQuery(user) { IncludeOwnedItems = true };
_libraryManager.ConfigureUserAccess(accessFilter, user);
return accessFilter;
}
/// <summary>
/// Get person by name.
/// </summary>
@@ -133,6 +133,7 @@ public class UniversalAudioController : BaseJellyfinApiController
var info = await _mediaInfoHelper.GetPlaybackInfo(
item,
user,
Request,
mediaSourceId)
.ConfigureAwait(false);
@@ -40,6 +40,7 @@ public class UserLibraryController : BaseJellyfinApiController
private readonly IDtoService _dtoService;
private readonly IUserViewManager _userViewManager;
private readonly IFileSystem _fileSystem;
private readonly IProviderManager _providerManager;
/// <summary>
/// Initializes a new instance of the <see cref="UserLibraryController"/> class.
@@ -50,13 +51,15 @@ public class UserLibraryController : BaseJellyfinApiController
/// <param name="dtoService">Instance of the <see cref="IDtoService"/> interface.</param>
/// <param name="userViewManager">Instance of the <see cref="IUserViewManager"/> interface.</param>
/// <param name="fileSystem">Instance of the <see cref="IFileSystem"/> interface.</param>
/// <param name="providerManager">Instance of the <see cref="IProviderManager"/> interface.</param>
public UserLibraryController(
IUserManager userManager,
IUserDataManager userDataRepository,
ILibraryManager libraryManager,
IDtoService dtoService,
IUserViewManager userViewManager,
IFileSystem fileSystem)
IFileSystem fileSystem,
IProviderManager providerManager)
{
_userManager = userManager;
_userDataRepository = userDataRepository;
@@ -64,6 +67,7 @@ public class UserLibraryController : BaseJellyfinApiController
_dtoService = dtoService;
_userViewManager = userViewManager;
_fileSystem = fileSystem;
_providerManager = providerManager;
}
/// <summary>
@@ -75,7 +79,7 @@ public class UserLibraryController : BaseJellyfinApiController
/// <returns>An <see cref="OkResult"/> containing the item.</returns>
[HttpGet("Items/{itemId}")]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<BaseItemDto>> GetItem(
public ActionResult<BaseItemDto> GetItem(
[FromQuery] Guid? userId,
[FromRoute, Required] Guid itemId)
{
@@ -94,7 +98,7 @@ public class UserLibraryController : BaseJellyfinApiController
return NotFound();
}
await RefreshItemOnDemandIfNeeded(item).ConfigureAwait(false);
QueueRefreshOnDemandIfNeeded(item);
var dtoOptions = new DtoOptions();
@@ -112,7 +116,7 @@ public class UserLibraryController : BaseJellyfinApiController
[ProducesResponseType(StatusCodes.Status200OK)]
[Obsolete("Kept for backwards compatibility")]
[ApiExplorerSettings(IgnoreApi = true)]
public Task<ActionResult<BaseItemDto>> GetItemLegacy(
public ActionResult<BaseItemDto> GetItemLegacy(
[FromRoute, Required] Guid userId,
[FromRoute, Required] Guid itemId)
=> GetItem(userId, itemId);
@@ -639,25 +643,28 @@ public class UserLibraryController : BaseJellyfinApiController
limit,
groupItems);
private async Task RefreshItemOnDemandIfNeeded(BaseItem item)
private void QueueRefreshOnDemandIfNeeded(BaseItem item)
{
if (item is Person)
if (item is not Person)
{
var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
var performFullRefresh = !hasMetadata && (DateTime.UtcNow - item.DateLastRefreshed).TotalDays >= 3;
if (performFullRefresh)
{
var options = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ImageRefreshMode = MetadataRefreshMode.FullRefresh,
ForceSave = true
};
await item.RefreshMetadata(options, CancellationToken.None).ConfigureAwait(false);
}
return;
}
var hasMetadata = !string.IsNullOrWhiteSpace(item.Overview) && item.HasImage(ImageType.Primary);
if (hasMetadata || (DateTime.UtcNow - item.DateLastRefreshed).TotalDays < 3)
{
return;
}
_providerManager.QueueRefresh(
item.Id,
new MetadataRefreshOptions(new DirectoryService(_fileSystem))
{
MetadataRefreshMode = MetadataRefreshMode.FullRefresh,
ImageRefreshMode = MetadataRefreshMode.FullRefresh,
ForceSave = true
},
RefreshPriority.High);
}
/// <summary>
+124 -24
View File
@@ -14,6 +14,7 @@ using Jellyfin.Database.Implementations.Enums;
using Jellyfin.Extensions;
using MediaBrowser.Common.Extensions;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Entities;
@@ -44,6 +45,7 @@ public class MediaInfoHelper
private readonly ILogger<MediaInfoHelper> _logger;
private readonly INetworkManager _networkManager;
private readonly IDeviceManager _deviceManager;
private readonly IServerApplicationHost _appHost;
/// <summary>
/// Initializes a new instance of the <see cref="MediaInfoHelper"/> class.
@@ -56,6 +58,7 @@ public class MediaInfoHelper
/// <param name="logger">Instance of the <see cref="ILogger{MediaInfoHelper}"/> interface.</param>
/// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param>
/// <param name="deviceManager">Instance of the <see cref="IDeviceManager"/> interface.</param>
/// <param name="appHost">Instance of the <see cref="IServerApplicationHost"/> interface.</param>
public MediaInfoHelper(
IUserManager userManager,
ILibraryManager libraryManager,
@@ -64,7 +67,8 @@ public class MediaInfoHelper
IServerConfigurationManager serverConfigurationManager,
ILogger<MediaInfoHelper> logger,
INetworkManager networkManager,
IDeviceManager deviceManager)
IDeviceManager deviceManager,
IServerApplicationHost appHost)
{
_userManager = userManager;
_libraryManager = libraryManager;
@@ -74,6 +78,7 @@ public class MediaInfoHelper
_logger = logger;
_networkManager = networkManager;
_deviceManager = deviceManager;
_appHost = appHost;
}
/// <summary>
@@ -81,40 +86,20 @@ public class MediaInfoHelper
/// </summary>
/// <param name="item">The item.</param>
/// <param name="user">The user.</param>
/// <param name="request">The current <see cref="HttpRequest"/>.</param>
/// <param name="mediaSourceId">Media source id.</param>
/// <param name="liveStreamId">Live stream id.</param>
/// <returns>A <see cref="Task"/> containing the <see cref="PlaybackInfoResponse"/>.</returns>
public async Task<PlaybackInfoResponse> GetPlaybackInfo(
BaseItem item,
User? user,
HttpRequest request,
string? mediaSourceId = null,
string? liveStreamId = null)
{
var result = new PlaybackInfoResponse();
MediaSourceInfo[] mediaSources;
if (string.IsNullOrWhiteSpace(liveStreamId))
{
// TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes?
var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(mediaSourceId))
{
mediaSources = mediaSourcesList.ToArray();
}
else
{
mediaSources = mediaSourcesList
.Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
.ToArray();
}
}
else
{
var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false);
mediaSources = new[] { mediaSource };
}
var mediaSources = await ResolvePlaybackMediaSources(item, user, mediaSourceId, liveStreamId).ConfigureAwait(false);
if (mediaSources.Length == 0)
{
@@ -136,6 +121,11 @@ public class MediaInfoHelper
mediaSourcesClone[i].DefaultAudioIndexSource = mediaSources[i].DefaultAudioIndexSource;
}
foreach (var mediaSource in mediaSourcesClone)
{
RewritePublishedLiveStreamPath(mediaSource, request);
}
result.MediaSources = mediaSourcesClone;
}
@@ -145,6 +135,28 @@ public class MediaInfoHelper
return result;
}
private async Task<MediaSourceInfo[]> ResolvePlaybackMediaSources(BaseItem item, User? user, string? mediaSourceId, string? liveStreamId)
{
if (!string.IsNullOrWhiteSpace(liveStreamId))
{
var mediaSource = await _mediaSourceManager.GetLiveStream(liveStreamId, CancellationToken.None).ConfigureAwait(false);
return new[] { mediaSource };
}
// TODO (moved from MediaBrowser.Api) handle supportedLiveMediaTypes?
var mediaSourcesList = await _mediaSourceManager.GetPlaybackMediaSources(item, user, true, true, CancellationToken.None).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(mediaSourceId))
{
return mediaSourcesList.ToArray();
}
return mediaSourcesList
.Where(i => string.Equals(i.Id, mediaSourceId, StringComparison.OrdinalIgnoreCase))
.ToArray();
}
/// <summary>
/// SetDeviceSpecificData.
/// </summary>
@@ -415,6 +427,8 @@ public class MediaInfoHelper
{
var result = await _mediaSourceManager.OpenLiveStream(request, CancellationToken.None).ConfigureAwait(false);
RewritePublishedLiveStreamPath(result.MediaSource, httpContext.Request);
var profile = request.DeviceProfile;
if (profile is null)
{
@@ -524,4 +538,90 @@ public class MediaInfoHelper
return maxBitrate;
}
/// <summary>
/// Rewrites a Live TV media source's <see cref="MediaSourceInfo.Path"/> to the request-appropriate published
/// URL when it points at a Jellyfin-hosted live stream buffer, so response copies never leak server-local
/// addresses. Only opened live streams are eligible. The shared instance held by
/// <see cref="IMediaSourceManager"/> is never touched by this method.
/// </summary>
/// <param name="mediaSource">The media source clone to rewrite in place.</param>
/// <param name="request">The current <see cref="HttpRequest"/>.</param>
private void RewritePublishedLiveStreamPath(MediaSourceInfo mediaSource, HttpRequest request)
{
// Opened live streams always carry a LiveStreamId; this excludes pre-open and plugin/remote sources.
if (string.IsNullOrEmpty(mediaSource.LiveStreamId))
{
return;
}
var baseUrl = _serverConfigurationManager.GetNetworkConfiguration().BaseUrl;
var publishedPath = GetPublishedLiveStreamPath(_appHost.GetSmartApiUrl(request), mediaSource.Path, mediaSource.Protocol, baseUrl);
if (publishedPath is not null)
{
mediaSource.Path = publishedPath;
return;
}
if (mediaSource.Path is not null && mediaSource.Path.Contains("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase))
{
_logger.LogDebug("Not rewriting live stream path for media source {MediaSourceId}: the local path did not resolve under the request's smart API URL/BaseUrl", mediaSource.Id);
}
}
/// <summary>
/// Resolves a Jellyfin-hosted Live TV buffer path to its request-appropriate published equivalent.
/// Returns null when the path isn't a Jellyfin-hosted <c>/LiveTv/LiveStreamFiles/</c> HTTP URL.
/// </summary>
/// <param name="smartApiUrl">The request-appropriate base URL, as returned by <see cref="IServerApplicationHost.GetSmartApiUrl(HttpRequest)"/>.</param>
/// <param name="localPath">The media source's local (LAN-access) path, as built from <see cref="IServerApplicationHost.GetApiUrlForLocalAccess"/>.</param>
/// <param name="protocol">The media source's protocol.</param>
/// <param name="baseUrl">The server's configured BaseUrl, if any.</param>
/// <returns>The published path, or null if the local path should be left unchanged.</returns>
internal static string? GetPublishedLiveStreamPath(
string smartApiUrl,
string? localPath,
MediaProtocol protocol,
string baseUrl)
{
if (protocol != MediaProtocol.Http
|| !Uri.TryCreate(localPath, UriKind.Absolute, out var localUri))
{
return null;
}
var relativePath = localUri.PathAndQuery;
if (!string.IsNullOrEmpty(baseUrl))
{
var basePrefix = baseUrl + "/";
if (!relativePath.StartsWith(basePrefix, StringComparison.OrdinalIgnoreCase))
{
return null;
}
relativePath = relativePath[baseUrl.Length..];
}
if (!relativePath.StartsWith("/LiveTv/LiveStreamFiles/", StringComparison.OrdinalIgnoreCase))
{
return null;
}
var prefix = smartApiUrl.TrimEnd('/');
if (!string.IsNullOrEmpty(baseUrl))
{
var includesBaseUrl = Uri.TryCreate(prefix, UriKind.Absolute, out var publishedUri)
&& Uri.UnescapeDataString(publishedUri.AbsolutePath)
.TrimEnd('/')
.EndsWith(baseUrl, StringComparison.OrdinalIgnoreCase);
if (!includesBaseUrl)
{
prefix += baseUrl;
}
}
return prefix + relativePath;
}
}
-5
View File
@@ -198,11 +198,6 @@ public static class StreamingHelpers
state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream, state.OutputAudioChannels) ?? 0;
}
if (outputAudioCodec.StartsWith("pcm_", StringComparison.Ordinal))
{
containerInternal = ".pcm";
}
if (state.VideoRequest is not null)
{
state.OutputVideoCodec = state.Request.VideoCodec;
+17
View File
@@ -162,6 +162,23 @@ public static class UserEntityExtensions
return Array.IndexOf(GetPreferenceValues<Guid>(entity, PreferenceKind.GroupedFolders), id) != -1;
}
/// <summary>
/// Checks whether any library, parental rating or tag rule keeps content from this user.
/// </summary>
/// <param name="entity">The user to check.</param>
/// <returns><c>True</c> if some content in the library is hidden from this user.</returns>
public static bool HasContentRestrictions(this User entity)
{
ArgumentNullException.ThrowIfNull(entity);
return !entity.HasPermission(PermissionKind.EnableAllFolders)
|| entity.GetPreference(PreferenceKind.BlockedMediaFolders).Length > 0
|| entity.MaxParentalRatingScore.HasValue
|| entity.GetPreference(PreferenceKind.BlockedTags).Length > 0
|| entity.GetPreference(PreferenceKind.AllowedTags).Length > 0
|| entity.GetPreference(PreferenceKind.BlockUnratedItems).Length > 0;
}
/// <summary>
/// Initializes the default permissions for a user. Should only be called on user creation.
/// </summary>
@@ -39,6 +39,19 @@ public static class ExpressionExtensions
return predicates.Aggregate((aggregatePredicate, nextPredicate) => aggregatePredicate.Or(nextPredicate));
}
/// <summary>
/// Negates a predicate.
/// </summary>
/// <typeparam name="T">The predicate parameter type.</typeparam>
/// <param name="predicate">The predicate expression to negate.</param>
/// <returns>A new expression representing the negation of the input predicate.</returns>
public static Expression<Func<T, bool>> Not<T>(this Expression<Func<T, bool>> predicate)
{
ArgumentNullException.ThrowIfNull(predicate);
return Expression.Lambda<Func<T, bool>>(Expression.Not(predicate.Body), predicate.Parameters);
}
/// <summary>
/// Combines two predicates into a single predicate using a logical AND operation.
/// </summary>
@@ -134,6 +134,21 @@ public static class BaseItemMapper
if (dto is Video video)
{
video.PrimaryVersionId = entity.PrimaryVersionId;
// The LinkedChildren table is the source of truth for version links
if (entity.LinkedChildEntities is not null)
{
video.LinkedAlternateVersions = entity.LinkedChildEntities
// LocalAlternateVersion links belong to Video.LocalAlternateVersions, not here
.Where(e => e.ChildType == Database.Implementations.Entities.LinkedChildType.LinkedAlternateVersion)
.OrderBy(e => e.SortOrder)
.Select(e => new LinkedChild
{
ItemId = e.ChildId,
Type = (MediaBrowser.Controller.Entities.LinkedChildType)e.ChildType
})
.ToArray();
}
}
if (dto is IHasSeries hasSeriesName)
@@ -183,7 +198,7 @@ public static class BaseItemMapper
if (dto is Folder folder)
{
folder.DateLastMediaAdded = entity.DateLastMediaAdded ?? DateTime.SpecifyKind(DateTime.MinValue, DateTimeKind.Utc);
if (entity.LinkedChildEntities is not null && entity.LinkedChildEntities.Count > 0)
if (entity.LinkedChildEntities is not null)
{
folder.LinkedChildren = entity.LinkedChildEntities
.OrderBy(e => e.SortOrder)
@@ -4,6 +4,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Dto;
@@ -109,7 +110,6 @@ public sealed partial class BaseItemRepository
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
@@ -144,11 +144,6 @@ public sealed partial class BaseItemRepository
{
ArgumentNullException.ThrowIfNull(filter);
if (!filter.Limit.HasValue)
{
filter.EnableTotalRecordCount = false;
}
using var context = _dbProvider.CreateDbContext();
var innerQueryFilter = TranslateQuery(context.BaseItems.Where(e => e.Id != EF.Constant(PlaceholderId)), context, new InternalItemsQuery(filter.User)
@@ -168,21 +163,16 @@ public sealed partial class BaseItemRepository
IsSeries = filter.IsSeries
});
// Keep this as an IQueryable sub-select. Materializing to a list would inline one
// bound parameter per CleanValue and hit SQLite's variable cap on libraries with
// high-cardinality value types (e.g. tens of thousands of artists).
var matchingCleanValues = context.ItemValuesMap
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
.Join(
innerQueryFilter,
ivm => ivm.ItemId,
g => g.Id,
(ivm, g) => ivm.ItemValue.CleanValue)
.Distinct();
var innerQuery = PrepareItemQuery(context, filter)
.Where(e => e.Type == returnType)
.Where(e => matchingCleanValues.Contains(e.CleanName!));
.Where(e => context.ItemValuesMap
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type) && ivm.ItemValue.CleanValue == e.CleanName)
.Join(
innerQueryFilter,
ivm => ivm.ItemId,
g => g.Id,
(ivm, g) => ivm.ItemId)
.Any());
var outerQueryFilter = new InternalItemsQuery(filter.User)
{
@@ -205,32 +195,42 @@ public sealed partial class BaseItemRepository
ExcludeItemIds = filter.ExcludeItemIds
};
// Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) by picking
// the lowest Id per group. For MusicArtist, prefer the entity from a library the user
// can actually access,since the same artist can have a folder in multiple libraries.
// Keep as an IQueryable sub-select so paging is applied AFTER
// ApplyOrder runs the caller's actual sort.
// Collapse rows that share a PresentationUniqueKey (e.g. alternate versions) into one
// representative id per group, then materialize the representative ids once.
var masterQuery = TranslateQuery(innerQuery, context, outerQueryFilter);
var isMusicArtist = returnType == _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicArtist];
var representativeIds = isMusicArtist
? masterQuery
List<Guid> representativeIds;
if (isMusicArtist)
{
// For MusicArtist, prefer the entity from a library the user can actually access.
// Materialize to prevent correlated per-group first-row queries which hurt performance.
var topParentIds = filter.TopParentIds;
representativeIds = masterQuery
.Select(e => new { e.Id, e.PresentationUniqueKey, e.TopParentId })
.AsEnumerable()
.GroupBy(e => e.PresentationUniqueKey)
.Select(g => g
.OrderBy(e => filter.TopParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1)
.OrderBy(e => topParentIds.Contains(e.TopParentId ?? Guid.Empty) ? 0 : 1)
.ThenBy(e => e.Id)
.First().Id)
: masterQuery
.ToList();
}
else
{
representativeIds = masterQuery
.GroupBy(e => e.PresentationUniqueKey)
.Select(g => g.Min(e => e.Id));
.Select(g => g.Min(e => e.Id))
.ToList();
}
var result = new QueryResult<(BaseItemDto, ItemCounts?)>();
if (filter.EnableTotalRecordCount)
{
result.TotalRecordCount = representativeIds.Count();
result.TotalRecordCount = representativeIds.Count;
}
var query = ApplyNavigations(
context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => representativeIds.Contains(e.Id)),
context.BaseItems.AsNoTracking().AsSingleQuery().WhereOneOrMany(representativeIds, e => e.Id),
filter);
query = ApplyOrder(query, filter, context);
@@ -311,8 +311,8 @@ public sealed partial class BaseItemRepository
var itemIds = itemCountQuery.Select(e => e.Id);
// Rewrite query to avoid SelectMany on navigation properties (which requires SQL APPLY, not supported on SQLite)
// Instead, start from ItemValueMaps and join with BaseItems
return context.ItemValuesMap
// Instead, start from ItemValueMaps and join with BaseItems.
var rawCounts = context.ItemValuesMap
.Where(ivm => itemValueTypes.Contains(ivm.ItemValue.Type))
.Where(ivm => itemIds.Contains(ivm.ItemId))
.Join(
@@ -322,18 +322,47 @@ public sealed partial class BaseItemRepository
(ivm, e) => new { CleanName = ivm.ItemValue.CleanValue, e.Type })
.GroupBy(x => new { x.CleanName, x.Type })
.Select(g => new { g.Key.CleanName, g.Key.Type, Count = g.Count() })
.GroupBy(x => x.CleanName)
.ToDictionary(
g => g.Key,
g => new ItemCounts
.AsEnumerable();
var countsByCleanName = new Dictionary<string, ItemCounts>();
foreach (var group in rawCounts.GroupBy(x => x.CleanName))
{
var counts = new ItemCounts();
foreach (var row in group)
{
if (row.Type == seriesTypeName)
{
SeriesCount = g.Where(x => x.Type == seriesTypeName).Sum(x => x.Count),
EpisodeCount = g.Where(x => x.Type == episodeTypeName).Sum(x => x.Count),
MovieCount = g.Where(x => x.Type == movieTypeName).Sum(x => x.Count),
AlbumCount = g.Where(x => x.Type == musicAlbumTypeName).Sum(x => x.Count),
ArtistCount = g.Where(x => x.Type == musicArtistTypeName).Sum(x => x.Count),
SongCount = g.Where(x => x.Type == audioTypeName).Sum(x => x.Count),
TrailerCount = g.Where(x => x.Type == trailerTypeName).Sum(x => x.Count),
});
counts.SeriesCount += row.Count;
}
else if (row.Type == episodeTypeName)
{
counts.EpisodeCount += row.Count;
}
else if (row.Type == movieTypeName)
{
counts.MovieCount += row.Count;
}
else if (row.Type == musicAlbumTypeName)
{
counts.AlbumCount += row.Count;
}
else if (row.Type == musicArtistTypeName)
{
counts.ArtistCount += row.Count;
}
else if (row.Type == audioTypeName)
{
counts.SongCount += row.Count;
}
else if (row.Type == trailerTypeName)
{
counts.TrailerCount += row.Count;
}
}
countsByCleanName[group.Key] = counts;
}
return countsByCleanName;
}
}
@@ -35,11 +35,40 @@ public sealed partial class BaseItemRepository
{
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
dbQuery = ApplyNavigations(dbQuery, filter);
return dbQuery;
}
/// <summary>
/// Trims an ordered query down to the AdjacentTo item and its immediate neighbours.
/// </summary>
private IQueryable<BaseItemEntity> ApplyAdjacencyFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
if (filter.AdjacentTo.IsNullOrEmpty())
{
return dbQuery;
}
// Adjacency is relative to the result set and the order the query asked for, so the ids have
// to be read back in that order.
var orderedIds = dbQuery.Select(e => e.Id).ToList();
var index = orderedIds.IndexOf(filter.AdjacentTo.Value);
if (index < 0)
{
// The item isn't part of this result set, so it has no neighbours in it either.
return dbQuery.Take(0);
}
var start = Math.Max(index - 1, 0);
var adjacentIds = orderedIds.GetRange(start, Math.Min(index + 2, orderedIds.Count) - start);
var adjacentQuery = context.BaseItems.AsNoTracking().AsSingleQuery().Where(e => adjacentIds.Contains(e.Id));
return ApplyOrder(adjacentQuery, filter, context);
}
private IQueryable<BaseItemEntity> ApplyQueryPaging(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
if (filter.Limit.HasValue || filter.StartIndex.HasValue)
@@ -244,8 +273,8 @@ public sealed partial class BaseItemRepository
dbQuery = dbQuery.Include(e => e.Images);
}
// Include LinkedChildEntities for container types and videos that use them
// (BoxSet, Playlist, CollectionFolder for manual linking; Video, Movie for alternate versions).
// Include LinkedChildEntities for container types and videos that use them (BoxSet, Playlist,
// CollectionFolder for manual linking; every video type for alternate versions).
// When IncludeItemTypes is empty (any type may be returned), always include them to ensure
// LinkedChildren are loaded before items are saved back, preventing accidental deletion.
var linkedChildTypes = new[]
@@ -254,7 +283,10 @@ public sealed partial class BaseItemRepository
BaseItemKind.Playlist,
BaseItemKind.CollectionFolder,
BaseItemKind.Video,
BaseItemKind.Movie
BaseItemKind.Movie,
BaseItemKind.Episode,
BaseItemKind.MusicVideo,
BaseItemKind.Trailer
};
if (filter.IncludeItemTypes.Length == 0 || filter.IncludeItemTypes.Any(linkedChildTypes.Contains))
{
@@ -390,11 +422,23 @@ public sealed partial class BaseItemRepository
var baseQuery = context.BaseItems
.AsNoTracking()
.Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem);
.Where(b => allDescendantIds.Contains(b.Id))
.Where(DescendantQueryHelper.IsCountableLeaf);
return ApplyAccessFiltering(context, baseQuery, filter);
}
/// <summary>
/// Checks whether the user restricts access to items by parental rating or tags.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns><c>true</c> if the query carries parental restrictions.</returns>
private static bool RequiresParentalRestrictions(InternalItemsQuery filter)
=> filter.IncludeInheritedTags.Length > 0
|| filter.ExcludeInheritedTags.Length > 0
|| filter.MaxParentalRating is not null
|| filter.BlockUnratedItems.Length > 0;
/// <summary>
/// Applies user access filtering to a query.
/// Includes TopParentIds, parental rating, and tag filtering.
@@ -405,13 +449,127 @@ public sealed partial class BaseItemRepository
IQueryable<BaseItemEntity> baseQuery,
InternalItemsQuery filter)
{
// Apply TopParentIds filtering (library folder access)
if (filter.TopParentIds.Length > 0)
baseQuery = ApplyTopParentFiltering(context, baseQuery, filter);
baseQuery = ApplyParentalRestrictions(context, baseQuery, filter);
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
if (!filter.IncludeOwnedItems)
{
var topParentIds = filter.TopParentIds;
baseQuery = baseQuery.Where(e => topParentIds.Contains(e.TopParentId!.Value));
baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
}
return baseQuery;
}
/// <summary>
/// Restricts a query to the libraries the user may open, exempting requested by-name items.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="baseQuery">The query to filter.</param>
/// <param name="filter">The query filter.</param>
/// <returns>The filtered query.</returns>
private IQueryable<BaseItemEntity> ApplyTopParentFiltering(
JellyfinDbContext context,
IQueryable<BaseItemEntity> baseQuery,
InternalItemsQuery filter)
{
var queryTopParentIds = filter.TopParentIds;
if (queryTopParentIds.Length == 0)
{
return baseQuery;
}
var exemptedItemByNameTypes = GetExemptedItemByNameTypes(filter);
if (exemptedItemByNameTypes.Count == 0)
{
return baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value);
}
baseQuery = baseQuery.Where(e => exemptedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value));
if (filter.UserHasContentRestrictions)
{
baseQuery = ApplyItemByNameAccessFiltering(baseQuery, context, filter, exemptedItemByNameTypes, queryTopParentIds);
}
return baseQuery;
}
/// <summary>
/// Returns the by-name types a query asks for, which carry no TopParentId to filter on.
/// </summary>
/// <param name="filter">The query filter.</param>
/// <returns>The type names exempt from library filtering.</returns>
private List<string> GetExemptedItemByNameTypes(InternalItemsQuery filter)
{
var includedItemByNameTypes = GetItemByNameTypesInQuery(filter);
if ((filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0)
{
return includedItemByNameTypes;
}
return _itemByNameKinds.Where(filter.IncludeItemTypes.Contains).Select(e => _itemTypeLookup.BaseItemKindNames[e]!).ToList();
}
/// <summary>
/// Keeps a by-name row only when at least one item behind its name is reachable for the user.
/// </summary>
/// <param name="baseQuery">The query to filter.</param>
/// <param name="context">The database context.</param>
/// <param name="filter">The query filter.</param>
/// <param name="itemByNameTypes">The exempted by-name type names.</param>
/// <param name="topParentIds">The libraries the user may open.</param>
/// <returns>The filtered query.</returns>
private IQueryable<BaseItemEntity> ApplyItemByNameAccessFiltering(
IQueryable<BaseItemEntity> baseQuery,
JellyfinDbContext context,
InternalItemsQuery filter,
IReadOnlyList<string> itemByNameTypes,
Guid[] topParentIds)
{
// IncludeOwnedItems: a credit on an alternate version of a reachable movie still counts.
var accessibleItems = ApplyAccessFiltering(
context,
context.BaseItems.AsNoTracking(),
new InternalItemsQuery(filter.User) { TopParentIds = topParentIds, IncludeOwnedItems = true });
var personType = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
if (itemByNameTypes.Contains(personType))
{
baseQuery = baseQuery.Where(e => e.Type != personType
|| context.Peoples.Any(p => p.Name == e.Name
&& context.PeopleBaseItemMap.Any(m => m.PeopleId == p.Id && accessibleItems.Any(i => i.Id == m.ItemId))));
}
foreach (var (kind, valueTypes) in _itemByNameValueTypes)
{
var typeName = _itemTypeLookup.BaseItemKindNames[kind];
if (!itemByNameTypes.Contains(typeName))
{
continue;
}
baseQuery = baseQuery.Where(e => e.Type != typeName
|| context.ItemValues.Any(v => valueTypes.Contains(v.Type) && v.CleanValue == e.CleanName
&& context.ItemValuesMap.Any(m => m.ItemValueId == v.ItemValueId && accessibleItems.Any(i => i.Id == m.ItemId))));
}
return baseQuery;
}
/// <summary>
/// Applies the user's parental rating and tag restrictions to a query.
/// </summary>
/// <param name="context">The database context.</param>
/// <param name="baseQuery">The query to filter.</param>
/// <param name="filter">The query filter.</param>
/// <returns>The filtered query.</returns>
private IQueryable<BaseItemEntity> ApplyParentalRestrictions(
JellyfinDbContext context,
IQueryable<BaseItemEntity> baseQuery,
InternalItemsQuery filter)
{
// Apply parental rating filtering
if (filter.MaxParentalRating is not null)
{
@@ -462,13 +620,6 @@ public sealed partial class BaseItemRepository
|| e.Type == personTypeName);
}
// Exclude alternate versions (have PrimaryVersionId set) and owned non-extra items.
// Extras (trailers, etc.) have OwnerId set but also have ExtraType set — keep those.
if (!filter.IncludeOwnedItems)
{
baseQuery = baseQuery.Where(e => e.PrimaryVersionId == null && (e.OwnerId == null || e.ExtraType != null));
}
return baseQuery;
}
@@ -503,62 +654,31 @@ public sealed partial class BaseItemRepository
}
/// <inheritdoc />
public IQueryable<Guid> GetFullyPlayedFolderIdsQuery(JellyfinDbContext context, IQueryable<Guid> folderIds, User user)
public IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery(JellyfinDbContext context, User user, bool includeOwnedItems = false)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(folderIds);
ArgumentNullException.ThrowIfNull(user);
var filter = new InternalItemsQuery(user);
var userId = user.Id;
var leafItems = context.BaseItems
.AsNoTracking()
.Where(b => !b.IsFolder && !b.IsVirtualItem);
leafItems = ApplyAccessFiltering(context, leafItems, filter);
.Where(DescendantQueryHelper.IsCountableLeaf);
var playedLeafItems = leafItems
.Select(b => new { b.Id, Played = b.UserData!.Any(ud => ud.UserId == userId && ud.Played) });
return ApplyAccessFiltering(context, leafItems, new InternalItemsQuery(user) { IncludeOwnedItems = includeOwnedItems });
}
var ancestorLeaves = context.AncestorIds
.Where(a => folderIds.Contains(a.ParentItemId))
.Join(
playedLeafItems,
a => a.ItemId,
b => b.Id,
(a, b) => new { FolderId = a.ParentItemId, b.Id, b.Played });
/// <inheritdoc />
public Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(JellyfinDbContext context, IQueryable<BaseItemEntity> descendants)
{
ArgumentNullException.ThrowIfNull(context);
ArgumentNullException.ThrowIfNull(descendants);
var linkedLeaves = context.LinkedChildren
.Where(lc => folderIds.Contains(lc.ParentId))
.Join(
playedLeafItems,
lc => lc.ChildId,
b => b.Id,
(lc, b) => new { FolderId = lc.ParentId, b.Id, b.Played });
var linkedFolderLeaves = context.LinkedChildren
.Where(lc => folderIds.Contains(lc.ParentId))
.Join(
context.BaseItems.Where(b => b.IsFolder),
lc => lc.ChildId,
b => b.Id,
(lc, b) => new { lc.ParentId, FolderChildId = b.Id })
.Join(
context.AncestorIds,
x => x.FolderChildId,
a => a.ParentItemId,
(x, a) => new { x.ParentId, DescendantId = a.ItemId })
.Join(
playedLeafItems,
x => x.DescendantId,
b => b.Id,
(x, b) => new { FolderId = x.ParentId, b.Id, b.Played });
return ancestorLeaves
.Union(linkedLeaves)
.Union(linkedFolderLeaves)
.GroupBy(x => x.FolderId)
.Where(g => g.Select(x => x.Id).Distinct().Count() == g.Where(x => x.Played).Select(x => x.Id).Distinct().Count())
.Select(g => g.Key);
// Descendants are reachable through the ancestor chain and - for BoxSets and Playlists - as
// linked children, which can themselves be folders contributing their own descendants.
// Every step is a correlated index seek, so only the rows the outer query keeps are visited
// and a folder is left as soon as its first matching descendant is found.
return e => context.AncestorIds.Any(a => a.ParentItemId == e.Id && descendants.Any(d => d.Id == a.ItemId))
|| context.LinkedChildren.Any(lc => lc.ParentId == e.Id
&& (descendants.Any(d => d.Id == lc.ChildId)
|| context.AncestorIds.Any(a => a.ParentItemId == lc.ChildId && descendants.Any(d => d.Id == a.ItemId))));
}
}
@@ -49,6 +49,7 @@ public sealed partial class BaseItemRepository
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter);
if (filter.EnableTotalRecordCount)
{
@@ -75,6 +76,7 @@ public sealed partial class BaseItemRepository
dbQuery = TranslateQuery(dbQuery, context, filter);
dbQuery = ApplyGroupingFilter(context, dbQuery, filter);
dbQuery = ApplyAdjacencyFilter(context, dbQuery, filter);
dbQuery = ApplyQueryPaging(dbQuery, filter);
var hasRandomSort = filter.OrderBy.Any(e => e.OrderBy == ItemSortBy.Random);
@@ -126,38 +128,57 @@ public sealed partial class BaseItemRepository
if (collectionType is CollectionType.movies)
{
// Group by PresentationUniqueKey, pick the newest item per group.
var topGroupItems = baseQuery
// Pick, per PresentationUniqueKey, the newest item; return the newest `limit` of those.
// Build up until limit by streaming through results and deduplicating on the fly.
var orderedIds = baseQuery
.Where(e => e.PresentationUniqueKey != null)
.GroupBy(e => e.PresentationUniqueKey)
.Select(g => new
{
MaxDate = g.Max(e => e.DateCreated),
FirstId = g.OrderByDescending(e => e.DateCreated).ThenByDescending(e => e.Id).Select(e => e.Id).First()
})
.OrderByDescending(g => g.MaxDate);
.OrderByDescending(e => e.DateCreated)
.ThenByDescending(e => e.Id)
.Select(e => new { e.Id, e.PresentationUniqueKey });
var firstIdsQuery = filter.Limit.HasValue
? topGroupItems.Take(filter.Limit.Value).Select(g => g.FirstId)
: topGroupItems.Select(g => g.FirstId);
// DistinctBy and Take are lazy, so enumeration stops as soon as limit distinct keys are read.
var firstIds = orderedIds
.AsEnumerable()
.DistinctBy(row => row.PresentationUniqueKey)
.Select(row => row.Id)
.Take(limit ?? int.MaxValue)
.ToList();
return LoadLatestByIds(context, firstIdsQuery, filter);
return LoadLatestByIds(context, firstIds, filter);
}
// Albums whose Id is the parent of any track matching the user's filter.
var albumIdsWithMatchingTrack = context.AncestorIds
.Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId);
var musicAlbumTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.MusicAlbum]!;
var topAlbumsQuery = context.BaseItems.AsNoTracking()
.Where(album => album.Type == musicAlbumTypeName)
.Where(album => albumIdsWithMatchingTrack.Contains(album.Id))
IQueryable<BaseItemEntity> topAlbumsQuery;
// When the query is scoped to whole libraries, read the newest albums directly by their own TopParentId.
if (filter.TopParentIds.Length > 0)
{
topAlbumsQuery = context.BaseItems.AsNoTracking()
.Where(album => album.Type == musicAlbumTypeName
&& !album.IsVirtualItem
&& album.TopParentId.HasValue)
.WhereOneOrMany(filter.TopParentIds, album => album.TopParentId!.Value);
}
else
{
// Fallback (e.g. AncestorIds-scoped callers): albums that are the parent of a matching track.
var albumIdsWithMatchingTrack = context.AncestorIds
.Join(baseQuery, ai => ai.ItemId, t => t.Id, (ai, _) => ai.ParentItemId);
topAlbumsQuery = context.BaseItems.AsNoTracking()
.Where(album => album.Type == musicAlbumTypeName)
.Where(album => albumIdsWithMatchingTrack.Contains(album.Id));
}
// The album is what gets returned, and neither branch above reads it through the
// user's filters, so its own parental restrictions have to be applied here: a
// matching track does not make an album the user may not see visible.
var orderedAlbums = ApplyParentalRestrictions(context, topAlbumsQuery, filter)
.OrderByDescending(album => album.DateCreated)
.ThenByDescending(album => album.Id);
var albumIdsQuery = filter.Limit.HasValue
? topAlbumsQuery.Take(filter.Limit.Value).Select(a => a.Id)
: topAlbumsQuery.Select(a => a.Id);
var albumIdsQuery = limit.HasValue
? orderedAlbums.Take(limit.Value).Select(a => a.Id)
: orderedAlbums.Select(a => a.Id);
return LoadLatestByIds(context, albumIdsQuery, filter);
}
@@ -181,6 +202,29 @@ public sealed partial class BaseItemRepository
.ToArray()!;
}
private IReadOnlyList<BaseItemDto> LoadLatestByIds(
JellyfinDbContext context,
List<Guid> ids,
InternalItemsQuery filter)
{
if (ids.Count == 0)
{
return [];
}
var itemsQuery = ApplyNavigations(
context.BaseItems.AsNoTracking().WhereOneOrMany(ids, e => e.Id),
filter);
return itemsQuery
.OrderByDescending(e => e.DateCreated)
.ThenByDescending(e => e.Id)
.AsEnumerable()
.Select(w => DeserializeBaseItem(w, filter.SkipDeserialization))
.Where(dto => dto != null)
.ToArray()!;
}
/// <summary>
/// Gets the latest TV show items with smart Season/Series container selection.
/// </summary>
@@ -381,6 +425,40 @@ public sealed partial class BaseItemRepository
seriesResults.Add((seasonId, seriesId, maxDate, mostRecentEpisodeId));
}
// Step 5b: A container is what gets returned, so it has to pass the user's access
// filters on its own - a matching episode does not make a Season or Series the user
// may not see visible. Containers that don't pass are replaced by their episode.
if (RequiresParentalRestrictions(filter) && entitiesToFetch.Count > 0)
{
var allowedContainerIds = ApplyParentalRestrictions(
context,
context.BaseItems.AsNoTracking().Where(e => entitiesToFetch.Contains(e.Id)),
filter)
.Select(e => e.Id)
.ToHashSet();
for (var i = 0; i < seriesResults.Count; i++)
{
var (seasonId, seriesId, maxDate, mostRecentEpisodeId) = seriesResults[i];
if (seasonId.HasValue && !allowedContainerIds.Contains(seasonId.Value))
{
seasonId = null;
}
if (seriesId.HasValue && !allowedContainerIds.Contains(seriesId.Value))
{
seriesId = null;
}
if (seasonId is null && seriesId is null)
{
entitiesToFetch.Add(mostRecentEpisodeId);
}
seriesResults[i] = (seasonId, seriesId, maxDate, mostRecentEpisodeId);
}
}
// Step 6: Fetch the Season/Series entities we decided to return
var entities = entitiesToFetch.Count > 0
? ApplyNavigations(
@@ -31,6 +31,10 @@ public sealed partial class BaseItemRepository
private static readonly string TmdbProviderName = MetadataProvider.Tmdb.ToString().ToLowerInvariant();
private static readonly string TvdbProviderName = MetadataProvider.Tvdb.ToString().ToLowerInvariant();
// A fresh expression per access: EF rejects a query tree that reuses one lambda parameter
// instance across several lambdas, and this filter is combined into a tree more than once.
private static Expression<Func<BaseItemEntity, bool>> IsFolderFilter => e => e.IsFolder;
/// <inheritdoc />
public IQueryable<BaseItemEntity> TranslateQuery(
IQueryable<BaseItemEntity> baseQuery,
@@ -352,7 +356,7 @@ public sealed partial class BaseItemRepository
}
else
{
baseQuery = baseQuery.Where(e => e.StartDate > now && e.EndDate < now);
baseQuery = baseQuery.Where(e => e.StartDate > now || e.EndDate < now);
}
}
@@ -366,14 +370,16 @@ public sealed partial class BaseItemRepository
p => p.Name,
(b, p) => p.Id);
var personTypes = filter.PersonTypes;
baseQuery = baseQuery
.Where(e => context.PeopleBaseItemMap
.Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId)));
.Any(m => m.ItemId == e.Id && peopleEntityIds.Contains(m.PeopleId) && (personTypes.Length == 0 || personTypes.Contains(m.People.PersonType))));
}
if (!string.IsNullOrWhiteSpace(filter.Person))
{
baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person));
var personTypes = filter.PersonTypes;
baseQuery = baseQuery.Where(e => e.Peoples!.Any(f => f.People.Name == filter.Person && (personTypes.Length == 0 || personTypes.Contains(f.People.PersonType))));
}
if (!string.IsNullOrWhiteSpace(filter.ExternalSeriesId))
@@ -434,115 +440,82 @@ public sealed partial class BaseItemRepository
if (filter.IsLiked.HasValue)
{
var isLiked = filter.IsLiked.Value;
baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue) == isLiked);
var likedIds = context.UserData
.Where(ud => ud.UserId == filter.User!.Id && ud.Rating >= UserItemData.MinLikeValue)
.Select(ud => ud.ItemId);
baseQuery = filter.IsLiked.Value
? baseQuery.Where(e => likedIds.Contains(e.Id))
: baseQuery.Where(e => !likedIds.Contains(e.Id));
}
if (filter.IsFavoriteOrLiked.HasValue)
if (filter.IsFavoriteOrLiked.HasValue || filter.IsFavorite.HasValue)
{
var isFavoriteOrLiked = filter.IsFavoriteOrLiked.Value;
baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavoriteOrLiked);
}
var favoriteIds = context.UserData
.Where(ud => ud.UserId == filter.User!.Id && ud.IsFavorite)
.Select(ud => ud.ItemId);
if (filter.IsFavorite.HasValue)
{
var isFavorite = filter.IsFavorite.Value;
baseQuery = baseQuery.Where(e => e.UserData!.Any(ud => ud.UserId == filter.User!.Id && ud.IsFavorite) == isFavorite);
if (filter.IsFavoriteOrLiked.HasValue)
{
baseQuery = filter.IsFavoriteOrLiked.Value
? baseQuery.Where(e => favoriteIds.Contains(e.Id))
: baseQuery.Where(e => !favoriteIds.Contains(e.Id));
}
if (filter.IsFavorite.HasValue)
{
baseQuery = filter.IsFavorite.Value
? baseQuery.Where(e => favoriteIds.Contains(e.Id))
: baseQuery.Where(e => !favoriteIds.Contains(e.Id));
}
}
if (filter.IsPlayed.HasValue)
{
var hasSeries = filter.IncludeItemTypes.Contains(BaseItemKind.Series);
var hasBoxSet = filter.IncludeItemTypes.Contains(BaseItemKind.BoxSet);
var userId = filter.User!.Id;
if (hasSeries || hasBoxSet)
{
var userId = filter.User!.Id;
var isPlayed = filter.IsPlayed.Value;
var seriesTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.Series];
var boxSetTypeName = _itemTypeLookup.BaseItemKindNames[BaseItemKind.BoxSet];
// Leaf items carry their own played state.
var playedItemIds = context.UserData
.Where(ud => ud.UserId == userId && ud.Played)
.Select(ud => ud.ItemId);
// Series: played = at least one episode AND all episodes played; unplayed = otherwise.
IQueryable<Guid> playedSeriesIds = hasSeries
? context.BaseItems
.AsNoTracking()
.Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue)
.GroupBy(e => e.SeriesId!.Value)
.Where(g => !g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
.Select(g => g.Key)
: Enumerable.Empty<Guid>().AsQueryable();
// Folders (Series, Seasons, BoxSets, albums, ...) have none and count as played once no
// descendant is left unplayed, matching what the DTO reports for them. This has to key off
// the item itself rather than off the requested item types: tag and collection listings mix
// folders and leaf items in a single query.
var unplayedLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!)
.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
// BoxSet: played = all children played.
IQueryable<Guid> playedBoxSetIds = hasBoxSet
? GetFullyPlayedFolderIdsQuery(
context,
baseQuery.Where(e => e.Type == boxSetTypeName).Select(e => e.Id),
filter.User!)
: Enumerable.Empty<Guid>().AsQueryable();
var isPlayedFilter = IsFolderFilter.And(BuildHasDescendantFilter(context, unplayedLeafItems).Not())
.Or(IsFolderFilter.Not().And(e => playedItemIds.Contains(e.Id)));
// Non-folder items: check UserData directly
var playedItemIds = context.UserData
.Where(ud => ud.UserId == userId && ud.Played)
.Select(ud => ud.ItemId);
if (isPlayed)
{
baseQuery = baseQuery.Where(e =>
(e.Type == seriesTypeName && playedSeriesIds.Contains(e.Id))
|| (e.Type == boxSetTypeName && playedBoxSetIds.Contains(e.Id))
|| (e.Type != seriesTypeName && e.Type != boxSetTypeName && playedItemIds.Contains(e.Id)));
}
else
{
baseQuery = baseQuery.Where(e =>
(e.Type == seriesTypeName && !playedSeriesIds.Contains(e.Id))
|| (e.Type == boxSetTypeName && !playedBoxSetIds.Contains(e.Id))
|| (e.Type != seriesTypeName && e.Type != boxSetTypeName && !playedItemIds.Contains(e.Id)));
}
}
else
{
var playedItemIds = context.UserData
.Where(ud => ud.UserId == filter.User!.Id && ud.Played)
.Select(ud => ud.ItemId);
var isPlayedItem = filter.IsPlayed.Value;
baseQuery = baseQuery.Where(e => playedItemIds.Contains(e.Id) == isPlayedItem);
}
baseQuery = baseQuery.Where(filter.IsPlayed.Value ? isPlayedFilter : isPlayedFilter.Not());
}
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)
{
// Aggregate per series in a single GROUP BY pass, instead of three full scans.
var seriesEpisodeStats = context.BaseItems
.AsNoTracking()
.Where(e => !e.IsFolder && !e.IsVirtualItem && e.SeriesId.HasValue)
.GroupBy(e => e.SeriesId!.Value)
.Select(g => new
{
SeriesId = g.Key,
HasInProgress = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0)),
HasPlayed = g.Any(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)),
HasUnplayed = g.Any(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))
});
// Series and Seasons are resumable when a descendant is in progress, or when they hold both
// played and unplayed descendants (partially watched). Alternate versions keep their own
// progress, so they count towards the in-progress check but not towards the played/unplayed one.
var leafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!);
var inProgressLeafItems = GetAccessFilteredLeafItemsQuery(context, filter.User!, includeOwnedItems: true)
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0));
// A series is resumable if it has an in-progress episode,
// or if it has both played and unplayed episodes (partially watched).
resumableSeriesIds = seriesEpisodeStats
.Where(s => s.HasInProgress || (s.HasPlayed && s.HasUnplayed))
.Select(s => s.SeriesId);
}
// Every other folder kind is a container rather than one continuous piece of media
var resumableFolderTypes = _resumableFolderKinds
.Select(kind => _itemTypeLookup.BaseItemKindNames.GetValueOrDefault(kind))
.ToArray();
var folderIsResumableFilter = IsFolderFilter.And(e => resumableFolderTypes.Contains(e.Type))
.And(BuildHasDescendantFilter(context, inProgressLeafItems)
.Or(BuildHasDescendantFilter(context, leafItems.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played)))
.And(BuildHasDescendantFilter(context, leafItems.Where(e => !e.UserData!.Any(ud => ud.UserId == userId && ud.Played))))));
if (isResumable)
{
@@ -550,26 +523,26 @@ public sealed partial class BaseItemRepository
// Match each version on its own progress rather than coalescing onto the primary.
var inProgressIds = inProgress.Select(ud => ud.ItemId);
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));
baseQuery = baseQuery.Where(folderIsResumableFilter
.Or(IsFolderFilter.Not().And(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)));
// Items in no version group at all have no sibling that could eliminate them, so short-circuit the scan for those.
baseQuery = baseQuery.Where(e => e.IsFolder
|| (e.PrimaryVersionId == null && !context.BaseItems.Any(a => a.PrimaryVersionId == e.Id))
|| !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
{
@@ -577,17 +550,14 @@ public sealed partial class BaseItemRepository
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));
baseQuery = baseQuery.Where(IsFolderFilter.And(folderIsResumableFilter.Not())
.Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id))));
}
}
if (filter.ArtistIds.Length > 0)
{
baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ArtistIds);
}
if (filter.AlbumArtistIds.Length > 0)
@@ -618,12 +588,12 @@ public sealed partial class BaseItemRepository
if (filter.ExcludeArtistIds.Length > 0)
{
baseQuery = baseQuery.WhereReferencedItemMultipleTypes(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
baseQuery = baseQuery.WhereReferencedItem(context, [ItemValueType.Artist, ItemValueType.AlbumArtist], filter.ExcludeArtistIds, true);
}
if (filter.GenreIds.Count > 0)
{
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds.ToArray());
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Genre, filter.GenreIds);
}
if (filter.Genres.Count > 0)
@@ -649,7 +619,7 @@ public sealed partial class BaseItemRepository
if (filter.StudioIds.Length > 0)
{
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds.ToArray());
baseQuery = baseQuery.WhereReferencedItem(context, ItemValueType.Studios, filter.StudioIds);
}
if (filter.OfficialRatings.Length > 0)
@@ -995,17 +965,6 @@ public sealed partial class BaseItemRepository
baseQuery = baseQuery.WhereHasAnyProviderIds(filter.HasAnyProviderIds);
}
if (filter.HasAnyProviderIds is not null && filter.HasAnyProviderIds.Count > 0)
{
var includeAny = filter.HasAnyProviderIds
.SelectMany(kvp => kvp.Value.Select(v => $"{kvp.Key}:{v}"))
.ToArray();
if (includeAny.Length > 0)
{
baseQuery = baseQuery.Where(e => e.Provider!.Select(f => f.ProviderId + ":" + f.ProviderValue)!.Any(f => includeAny.Contains(f)));
}
}
if (filter.HasImdbId.HasValue)
{
baseQuery = filter.HasImdbId.Value
@@ -1027,21 +986,7 @@ public sealed partial class BaseItemRepository
: baseQuery.Where(e => e.Provider!.All(f => f.ProviderId.ToLower() != TvdbProviderName));
}
var queryTopParentIds = filter.TopParentIds;
if (queryTopParentIds.Length > 0)
{
var includedItemByNameTypes = GetItemByNameTypesInQuery(filter);
var enableItemsByName = (filter.IncludeItemsByName ?? false) && includedItemByNameTypes.Count > 0;
if (enableItemsByName && includedItemByNameTypes.Count > 0)
{
baseQuery = baseQuery.Where(e => includedItemByNameTypes.Contains(e.Type) || queryTopParentIds.Any(w => w == e.TopParentId!.Value));
}
else
{
baseQuery = baseQuery.WhereOneOrMany(queryTopParentIds, e => e.TopParentId!.Value);
}
}
baseQuery = ApplyTopParentFiltering(context, baseQuery, filter);
if (filter.AncestorIds.Length > 0)
{
@@ -1150,16 +1095,23 @@ public sealed partial class BaseItemRepository
: baseQuery.WhereNeitherItemNorDescendantMatches(context, isPlaceHolder);
}
// An extra is owned by the single version of an item it is named after, so an extra on any
// version counts for the item itself
IQueryable<Guid> WithPrimaryVersions(IQueryable<Guid> ownerIds)
=> ownerIds.Concat(context.BaseItems
.Where(version => version.PrimaryVersionId != null && ownerIds.Contains(version.Id))
.Select(version => version.PrimaryVersionId!.Value));
if (filter.HasSpecialFeature.HasValue)
{
var itemsWithExtras = context.BaseItems
var itemsWithExtras = WithPrimaryVersions(context.BaseItems
.Where(extra => extra.OwnerId != null
&& extra.ExtraType != null
&& extra.ExtraType != BaseItemExtraType.Unknown
&& extra.ExtraType != BaseItemExtraType.Trailer
&& extra.ExtraType != BaseItemExtraType.ThemeSong
&& extra.ExtraType != BaseItemExtraType.ThemeVideo)
.Select(extra => extra.OwnerId!.Value)
.Select(extra => extra.OwnerId!.Value))
.Distinct();
Expression<Func<BaseItemEntity, bool>> hasExtras = e => itemsWithExtras.Contains(e.Id);
@@ -1171,9 +1123,9 @@ public sealed partial class BaseItemRepository
if (filter.HasTrailer.HasValue)
{
var trailerOwnerIds = context.BaseItems
var trailerOwnerIds = WithPrimaryVersions(context.BaseItems
.Where(extra => extra.ExtraType == BaseItemExtraType.Trailer && extra.OwnerId != null)
.Select(extra => extra.OwnerId!.Value);
.Select(extra => extra.OwnerId!.Value));
Expression<Func<BaseItemEntity, bool>> hasTrailer = e => trailerOwnerIds.Contains(e.Id);
@@ -1184,9 +1136,9 @@ public sealed partial class BaseItemRepository
if (filter.HasThemeSong.HasValue)
{
var themeSongOwnerIds = context.BaseItems
var themeSongOwnerIds = WithPrimaryVersions(context.BaseItems
.Where(extra => extra.ExtraType == BaseItemExtraType.ThemeSong && extra.OwnerId != null)
.Select(extra => extra.OwnerId!.Value);
.Select(extra => extra.OwnerId!.Value));
Expression<Func<BaseItemEntity, bool>> hasThemeSong = e => themeSongOwnerIds.Contains(e.Id);
@@ -1197,9 +1149,9 @@ public sealed partial class BaseItemRepository
if (filter.HasThemeVideo.HasValue)
{
var themeVideoOwnerIds = context.BaseItems
var themeVideoOwnerIds = WithPrimaryVersions(context.BaseItems
.Where(extra => extra.ExtraType == BaseItemExtraType.ThemeVideo && extra.OwnerId != null)
.Select(extra => extra.OwnerId!.Value);
.Select(extra => extra.OwnerId!.Value));
Expression<Func<BaseItemEntity, bool>> hasThemeVideo = e => themeVideoOwnerIds.Contains(e.Id);
@@ -1226,33 +1178,6 @@ public sealed partial class BaseItemRepository
}
}
if (filter.AdjacentTo.HasValue && !filter.AdjacentTo.Value.IsEmpty())
{
var adjacentToId = filter.AdjacentTo.Value;
var targetItem = context.BaseItems.Where(e => e.Id == adjacentToId).Select(e => new { e.SortName, e.Id }).FirstOrDefault();
if (targetItem is not null)
{
var targetSortName = targetItem.SortName ?? string.Empty;
// Fetch both prev and next adjacent items in a single query using Concat (UNION ALL).
var adjacentIds = context.BaseItems
.Where(e => string.Compare(e.SortName, targetSortName) < 0)
.OrderByDescending(e => e.SortName)
.Select(e => e.Id)
.Take(1)
.Concat(
context.BaseItems
.Where(e => string.Compare(e.SortName, targetSortName) > 0)
.OrderBy(e => e.SortName)
.Select(e => e.Id)
.Take(1))
.ToList();
adjacentIds.Add(adjacentToId);
baseQuery = baseQuery.Where(e => adjacentIds.Contains(e.Id));
}
}
return baseQuery;
}
}
@@ -46,6 +46,31 @@ public sealed partial class BaseItemRepository
private static readonly IReadOnlyList<ItemValueType> _getStudiosValueTypes = [ItemValueType.Studios];
private static readonly IReadOnlyList<ItemValueType> _getGenreValueTypes = [ItemValueType.Genre];
private static readonly BaseItemKind[] _itemByNameKinds =
[
BaseItemKind.Person,
BaseItemKind.Genre,
BaseItemKind.MusicGenre,
BaseItemKind.MusicArtist,
BaseItemKind.Studio
];
private static readonly (BaseItemKind Kind, IReadOnlyList<ItemValueType> ValueTypes)[] _itemByNameValueTypes =
[
(BaseItemKind.Genre, _getGenreValueTypes),
(BaseItemKind.MusicGenre, _getGenreValueTypes),
(BaseItemKind.MusicArtist, _getAllArtistsValueTypes),
(BaseItemKind.Studio, _getStudiosValueTypes)
];
// The only folder kinds whose children form a single viewing sequence, so playback progress on a
// child rolls up to them. Every other folder kind is a container that cannot be resumed.
private static readonly BaseItemKind[] _resumableFolderKinds =
[
BaseItemKind.Series,
BaseItemKind.Season
];
/// <summary>
/// Initializes a new instance of the <see cref="BaseItemRepository"/> class.
/// </summary>
@@ -141,32 +141,32 @@ public class ItemCountService : IItemCountService
switch (kind)
{
case BaseItemKind.Person:
baseQuery = context.PeopleBaseItemMap
baseQuery = ItemsById(context, context.PeopleBaseItemMap
.AsNoTracking()
.Where(m => m.People.Name == item.Name)
.Select(m => m.Item);
.Select(m => m.ItemId));
break;
case BaseItemKind.MusicArtist:
baseQuery = context.ItemValuesMap
baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& (ivm.ItemValue.Type == ItemValueType.Artist || ivm.ItemValue.Type == ItemValueType.AlbumArtist))
.Select(ivm => ivm.Item);
.Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Genre:
case BaseItemKind.MusicGenre:
baseQuery = context.ItemValuesMap
baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& ivm.ItemValue.Type == ItemValueType.Genre)
.Select(ivm => ivm.Item);
.Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Studio:
baseQuery = context.ItemValuesMap
baseQuery = ItemsById(context, context.ItemValuesMap
.AsNoTracking()
.Where(ivm => ivm.ItemValue.CleanValue == item.CleanName
&& ivm.ItemValue.Type == ItemValueType.Studios)
.Select(ivm => ivm.Item);
.Select(ivm => ivm.ItemId));
break;
case BaseItemKind.Year:
if (int.TryParse(item.Name, NumberStyles.Integer, CultureInfo.InvariantCulture, out var year))
@@ -254,6 +254,9 @@ public class ItemCountService : IItemCountService
return result;
}
private static IQueryable<BaseItemEntity> ItemsById(JellyfinDbContext context, IQueryable<Guid> itemIds)
=> context.BaseItems.AsNoTracking().Where(e => itemIds.Contains(e.Id));
/// <inheritdoc/>
public int GetPlayedCount(InternalItemsQuery filter, Guid ancestorId)
{
@@ -293,7 +296,8 @@ public class ItemCountService : IItemCountService
var allDescendantIds = DescendantQueryHelper.GetAllDescendantIds(dbContext, parentId);
var baseQuery = dbContext.BaseItems
.Where(b => allDescendantIds.Contains(b.Id) && !b.IsFolder && !b.IsVirtualItem);
.Where(b => allDescendantIds.Contains(b.Id))
.Where(DescendantQueryHelper.IsCountableLeaf);
baseQuery = _queryHelpers.ApplyAccessFiltering(dbContext, baseQuery, filter);
return GetPlayedAndTotalCountFromQuery(baseQuery, filter.User.Id);
@@ -354,7 +358,7 @@ public class ItemCountService : IItemCountService
var userId = user.Id;
var leafItems = dbContext.BaseItems
.Where(b => !b.IsFolder && !b.IsVirtualItem);
.Where(DescendantQueryHelper.IsCountableLeaf);
leafItems = _queryHelpers.ApplyAccessFiltering(dbContext, leafItems, filter);
var playedLeafItems = leafItems
@@ -257,23 +257,19 @@ public class ItemPersistenceService : IItemPersistenceService
using var transaction = context.Database.BeginTransaction();
var ids = tuples.Select(f => f.Item.Id).ToArray();
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToArray();
var existingItems = context.BaseItems.Where(e => ids.Contains(e.Id)).Select(f => f.Id).ToHashSet();
foreach (var item in tuples)
{
var entity = BaseItemMapper.Map(item.Item, _appHost);
entity.TopParentId = item.TopParent?.Id;
if (!existingItems.Any(e => e == entity.Id))
if (!existingItems.Contains(entity.Id))
{
context.BaseItems.Add(entity);
}
else
{
context.BaseItemProviders.Where(e => e.ItemId == entity.Id).ExecuteDelete();
context.BaseItemImageInfos.Where(e => e.ItemId == entity.Id).ExecuteDelete();
context.BaseItemMetadataFields.Where(e => e.ItemId == entity.Id).ExecuteDelete();
if (entity.Images is { Count: > 0 })
{
context.BaseItemImageInfos.AddRange(entity.Images);
@@ -314,9 +310,11 @@ public class ItemPersistenceService : IItemPersistenceService
}).ToArray();
context.ItemValues.AddRange(missingItemValues);
var itemValuesStore = existingValues.Concat(missingItemValues).ToArray();
var itemValuesStore = existingValues
.Concat(missingItemValues)
.ToDictionary(e => (e.Type, e.Value));
var valueMap = itemValueMaps
.Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore.First(g => g.Value == e.Value && g.Type == e.MagicNumber)).DistinctBy(e => e.ItemValueId).ToArray()))
.Select(f => (f.Item, Values: f.Values.Select(e => itemValuesStore[(e.MagicNumber, e.Value)]).DistinctBy(e => e.ItemValueId).ToArray()))
.ToArray();
var mappedValues = context.ItemValuesMap.Where(e => ids.Contains(e.ItemId)).ToList();
@@ -401,6 +399,15 @@ public class ItemPersistenceService : IItemPersistenceService
}
}
// Owned rows of updated items are rewritten wholesale; cleared in one statement per table.
if (existingItems.Count > 0)
{
var updatedIds = existingItems.ToArray();
context.BaseItemProviders.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
context.BaseItemImageInfos.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
context.BaseItemMetadataFields.WhereOneOrMany(updatedIds, e => e.ItemId).ExecuteDelete();
}
context.SaveChanges();
var folderIds = tuples
@@ -428,106 +435,144 @@ public class ItemPersistenceService : IItemPersistenceService
foreach (var item in tuples)
{
if (item.Item is Folder folder)
// A container that was never hydrated cannot be used to rewrite its links: its empty
// array means "unknown", so clearing the stored rows would silently empty the item.
if (item.Item is Folder { LinkedChildrenLoaded: false })
{
var existingLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(item.Item.Id)?.ToList() ?? new List<LinkedChildEntity>();
if (folder.LinkedChildren.Length > 0)
{
#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data
var pathsToResolve = folder.LinkedChildren
.Where(lc => (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty()) && !string.IsNullOrEmpty(lc.Path))
.Select(lc => lc.Path)
.Distinct()
continue;
}
if (item.Item is Folder or Video
&& allLinkedChildrenByParent.TryGetValue(item.Item.Id, out var existingLinks)
&& existingLinks.Count > 0)
{
// A video only owns its alternate version links; any other link on that parent is
// written by the folder branch below and must survive.
var staleLinks = item.Item is Folder
? existingLinks
: existingLinks
.Where(e => e.ChildType is DbLinkedChildType.LocalAlternateVersion or DbLinkedChildType.LinkedAlternateVersion)
.ToList();
var pathToIdMap = pathsToResolve.Count > 0
? context.BaseItems
.Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
.Select(e => new { e.Path, e.Id })
.GroupBy(e => e.Path!)
.ToDictionary(g => g.Key, g => g.First().Id)
: [];
if (staleLinks.Count > 0)
{
context.LinkedChildren.RemoveRange(staleLinks);
}
}
}
var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
foreach (var linkedChild in folder.LinkedChildren)
context.SaveChanges();
// A LinkedChild's ItemId is only a cache.
var cachedChildIds = tuples
.Select(t => t.Item)
.OfType<Folder>()
.Where(f => f.LinkedChildrenLoaded)
.SelectMany(f => f.LinkedChildren)
.Where(lc => lc.ItemId.HasValue && !lc.ItemId.Value.IsEmpty())
.Select(lc => lc.ItemId!.Value)
.Distinct()
.ToList();
var knownChildIds = cachedChildIds.Count > 0
? context.BaseItems
.WhereOneOrMany(cachedChildIds, e => e.Id)
.Select(e => e.Id)
.ToHashSet()
: [];
foreach (var item in tuples)
{
if (item.Item is Folder { LinkedChildrenLoaded: true } folder && folder.LinkedChildren.Length > 0)
{
#pragma warning disable CS0618 // Type or member is obsolete - legacy path resolution for old data
var pathsToResolve = folder.LinkedChildren
.Where(lc => !string.IsNullOrEmpty(lc.Path)
&& (!lc.ItemId.HasValue || lc.ItemId.Value.IsEmpty() || !knownChildIds.Contains(lc.ItemId.Value)))
.Select(lc => lc.Path)
.Distinct()
.ToList();
var pathToIdMap = pathsToResolve.Count > 0
? context.BaseItems
.Where(e => e.Path != null && pathsToResolve.Contains(e.Path))
.Select(e => new { e.Path, e.Id })
.GroupBy(e => e.Path!)
.ToDictionary(g => g.Key, g => g.First().Id)
: [];
var resolvedChildren = new List<(LinkedChild Child, Guid ChildId)>();
foreach (var linkedChild in folder.LinkedChildren)
{
var childItemId = linkedChild.ItemId;
if (!childItemId.HasValue || childItemId.Value.IsEmpty() || !knownChildIds.Contains(childItemId.Value))
{
var childItemId = linkedChild.ItemId;
if (!childItemId.HasValue || childItemId.Value.IsEmpty())
if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
{
if (!string.IsNullOrEmpty(linkedChild.Path) && pathToIdMap.TryGetValue(linkedChild.Path, out var resolvedId))
{
childItemId = resolvedId;
}
childItemId = resolvedId;
}
#pragma warning restore CS0618
if (childItemId.HasValue && !childItemId.Value.IsEmpty())
else if (Guid.TryParse(linkedChild.LibraryItemId, out var libraryItemId) && !libraryItemId.IsEmpty())
{
resolvedChildren.Add((linkedChild, childItemId.Value));
childItemId = libraryItemId;
}
}
#pragma warning restore CS0618
if (childItemId.HasValue && !childItemId.Value.IsEmpty())
{
resolvedChildren.Add((linkedChild, childItemId.Value));
}
}
// Playlists may legitimately contain the same item multiple times (e.g. a song repeated
// in an .m3u file). Every other container type keeps a single entry per child.
var isPlaylist = folder is Playlist;
if (!isPlaylist)
{
resolvedChildren = resolvedChildren
.GroupBy(c => c.ChildId)
.Select(g => g.Last())
.ToList();
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).ToList();
var existingChildIds = childIdsToCheck.Count > 0
? context.BaseItems
.Where(e => childIdsToCheck.Contains(e.Id))
.Select(e => e.Id)
.ToHashSet()
: [];
var isPlaylist = folder is Playlist;
var sortOrder = 0;
foreach (var (linkedChild, childId) in resolvedChildren)
{
if (!existingChildIds.Contains(childId))
{
_logger.LogWarning(
"Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} does not exist in database",
item.Item.Name,
item.Item.Id,
childId);
continue;
}
var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
if (existingLink is null)
{
context.LinkedChildren.Add(new LinkedChildEntity()
{
ParentId = item.Item.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)linkedChild.Type,
SortOrder = isPlaylist ? sortOrder : null
});
}
else
{
existingLink.SortOrder = isPlaylist ? sortOrder : null;
existingLink.ChildType = (DbLinkedChildType)linkedChild.Type;
existingLinkedChildren.Remove(existingLink);
}
sortOrder++;
}
}
if (existingLinkedChildren.Count > 0)
var childIdsToCheck = resolvedChildren.Select(c => c.ChildId).Distinct().ToList();
var existingChildIds = childIdsToCheck.Count > 0
? context.BaseItems
.Where(e => childIdsToCheck.Contains(e.Id))
.Select(e => e.Id)
.ToHashSet()
: [];
var sortOrder = 0;
foreach (var (linkedChild, childId) in resolvedChildren)
{
context.LinkedChildren.RemoveRange(existingLinkedChildren);
if (!existingChildIds.Contains(childId))
{
#pragma warning disable CS0618 // Type or member is obsolete - legacy path is logged for diagnostics
_logger.LogWarning(
"Skipping LinkedChild for parent {ParentName} ({ParentId}): child item {ChildId} (path {ChildPath}) does not exist in database",
item.Item.Name,
item.Item.Id,
childId,
linkedChild.Path ?? "unknown");
#pragma warning restore CS0618
continue;
}
context.LinkedChildren.Add(new LinkedChildEntity()
{
ParentId = item.Item.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)linkedChild.Type,
SortOrder = sortOrder
});
sortOrder++;
}
}
if (item.Item is Video video)
{
var existingLinkedChildren = (allLinkedChildrenByParent.GetValueOrDefault(video.Id) ?? new List<LinkedChildEntity>())
.Where(e => (int)e.ChildType == 2 || (int)e.ChildType == 3)
.ToList();
var newLinkedChildren = new List<(Guid ChildId, LinkedChildType Type)>();
if (video.LocalAlternateVersions.Length > 0)
@@ -577,7 +622,7 @@ public class ItemPersistenceService : IItemPersistenceService
.ToHashSet()
: [];
int sortOrder = 0;
var sortOrder = 0;
foreach (var (childId, childType) in newLinkedChildren)
{
if (!existingChildIds.Contains(childId))
@@ -590,36 +635,27 @@ public class ItemPersistenceService : IItemPersistenceService
continue;
}
var existingLink = existingLinkedChildren.FirstOrDefault(e => e.ChildId == childId);
if (existingLink is null)
context.LinkedChildren.Add(new LinkedChildEntity
{
context.LinkedChildren.Add(new LinkedChildEntity
{
ParentId = video.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)childType,
SortOrder = sortOrder
});
}
else
{
existingLink.ChildType = (DbLinkedChildType)childType;
existingLink.SortOrder = sortOrder;
existingLinkedChildren.Remove(existingLink);
}
ParentId = video.Id,
ChildId = childId,
ChildType = (DbLinkedChildType)childType,
SortOrder = sortOrder
});
sortOrder++;
}
if (existingLinkedChildren.Count > 0)
// A previously-linked LocalAlternateVersion that is no longer present becomes orphaned;
var previousLinkedChildren = allLinkedChildrenByParent.GetValueOrDefault(video.Id);
if (previousLinkedChildren is { Count: > 0 })
{
var orphanedLocalVersionIds = existingLinkedChildren
.Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion)
var newChildIds = newLinkedChildren.Select(c => c.ChildId).ToHashSet();
var orphanedLocalVersionIds = previousLinkedChildren
.Where(e => e.ChildType == DbLinkedChildType.LocalAlternateVersion && !newChildIds.Contains(e.ChildId))
.Select(e => e.ChildId)
.ToList();
context.LinkedChildren.RemoveRange(existingLinkedChildren);
if (orphanedLocalVersionIds.Count > 0)
{
var orphanedItems = context.BaseItems
@@ -59,6 +59,25 @@ public class LinkedChildrenService : ILinkedChildrenService
.ToArray();
}
/// <inheritdoc/>
public IReadOnlySet<Guid> GetItemIdsWithAlternateVersions(IReadOnlyList<Guid> itemIds)
{
if (itemIds.Count == 0)
{
return new HashSet<Guid>();
}
using var dbContext = _dbProvider.CreateDbContext();
return dbContext.LinkedChildren
.Where(lc => lc.ChildType == DbLinkedChildType.LocalAlternateVersion
|| lc.ChildType == DbLinkedChildType.LinkedAlternateVersion)
.WhereOneOrMany(itemIds, lc => lc.ParentId)
.Select(lc => lc.ParentId)
.Distinct()
.ToHashSet();
}
/// <inheritdoc/>
public IReadOnlyDictionary<string, MusicArtist[]> FindArtists(IReadOnlyList<string> artistNames)
{
@@ -159,12 +178,16 @@ public class LinkedChildrenService : ILinkedChildrenService
if (existingLink is null)
{
var nextSortOrder = (context.LinkedChildren
.Where(lc => lc.ParentId == parentId)
.Max(lc => (int?)lc.SortOrder) ?? -1) + 1;
context.LinkedChildren.Add(new Jellyfin.Database.Implementations.Entities.LinkedChildEntity
{
ParentId = parentId,
ChildId = childId,
ChildType = dbChildType,
SortOrder = null
SortOrder = nextSortOrder
});
}
else
@@ -98,7 +98,7 @@ public class NextUpService : INextUpService
.Where(e => e.UserData!.Any(ud => ud.UserId == userId && ud.Played));
lastWatchedBase = _queryHelpers.ApplyAccessFiltering(context, lastWatchedBase, filter);
// Use lightweight projection + client-side grouping to avoid correlated scalar subquery
// Use lightweight projection + client-side dedup to avoid the correlated scalar subquery
// per group that EF generates for GroupBy+OrderByDescending+FirstOrDefault.
var allPlayedLite = lastWatchedBase
.Select(e => new
@@ -110,15 +110,11 @@ public class NextUpService : INextUpService
})
.ToList();
var lastWatchedInfo = new Dictionary<string, Guid>();
foreach (var group in allPlayedLite.GroupBy(e => e.SeriesPresentationUniqueKey))
{
var lastWatched = group
.OrderByDescending(e => e.ParentIndexNumber)
.ThenByDescending(e => e.IndexNumber)
.First();
lastWatchedInfo[group.Key!] = lastWatched.Id;
}
var lastWatchedInfo = allPlayedLite
.OrderByDescending(e => e.ParentIndexNumber)
.ThenByDescending(e => e.IndexNumber)
.DistinctBy(e => e.SeriesPresentationUniqueKey)
.ToDictionary(e => e.SeriesPresentationUniqueKey!, e => e.Id);
Dictionary<string, Guid> lastWatchedByDateInfo = new();
if (includeWatchedForRewatching)
@@ -144,11 +140,10 @@ public class NextUpService : INextUpService
(e, ud) => new { EpisodeId = e.Id, e.SeriesPresentationUniqueKey, ud.LastPlayedDate })
.ToList();
foreach (var group in playedWithDates.GroupBy(x => x.SeriesPresentationUniqueKey))
{
var mostRecent = group.OrderByDescending(x => x.LastPlayedDate).First();
lastWatchedByDateInfo[group.Key!] = mostRecent.EpisodeId;
}
lastWatchedByDateInfo = playedWithDates
.OrderByDescending(x => x.LastPlayedDate)
.DistinctBy(x => x.SeriesPresentationUniqueKey)
.ToDictionary(x => x.SeriesPresentationUniqueKey!, x => x.EpisodeId);
}
var allLastWatchedIds = lastWatchedInfo.Values
@@ -29,19 +29,30 @@ public static class OrderMapper
/// <returns>Func to be executed later for sorting query.</returns>
public static Expression<Func<BaseItemEntity, object?>> MapOrderByField(ItemSortBy sortBy, InternalItemsQuery query, JellyfinDbContext jellyfinDbContext)
{
if (sortBy == ItemSortBy.DatePlayed)
{
// An item's played date is the newest of its own progress and that of its alternate versions,
// which track progress under their own ids. Matching both in one predicate ORs them together,
// which no index can serve: the user's whole UserData table gets scanned per sorted row.
// Two indexed lookups combined by MAX cost a seek each instead.
var userData = query.User is null
? jellyfinDbContext.UserData
: jellyfinDbContext.UserData.Where(w => w.UserId == query.User.Id);
return e => userData
.Where(w => w.ItemId == e.Id)
.Select(w => w.LastPlayedDate)
.Concat(userData
.Where(w => w.Item!.PrimaryVersionId == e.Id)
.Select(w => w.LastPlayedDate))
.Max();
}
return (sortBy, query.User) switch
{
(ItemSortBy.AirTime, _) => e => e.SortName,
(ItemSortBy.Runtime, _) => e => e.RunTimeTicks,
(ItemSortBy.Random, _) => e => EF.Functions.Random(),
(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,
@@ -57,7 +68,7 @@ public static class OrderMapper
(ItemSortBy.DateCreated, _) => e => e.DateCreated,
(ItemSortBy.PremiereDate, _) => e => e.PremiereDate ?? (e.ProductionYear.HasValue ? DateTime.MinValue.AddYears(e.ProductionYear.Value - 1) : null),
(ItemSortBy.StartDate, _) => e => e.StartDate,
(ItemSortBy.Name, _) => e => e.SortName,
(ItemSortBy.Name, _) => e => e.CleanName,
(ItemSortBy.CommunityRating, _) => e => e.CommunityRating,
(ItemSortBy.ProductionYear, _) => e => e.ProductionYear,
(ItemSortBy.CriticRating, _) => e => e.CriticRating,
@@ -21,10 +21,11 @@ namespace Jellyfin.Server.Implementations.Item;
/// </summary>
/// <param name="dbProvider">Efcore Factory.</param>
/// <param name="itemTypeLookup">Items lookup service.</param>
/// <param name="queryHelpers">Shared item query helpers.</param>
/// <remarks>
/// Initializes a new instance of the <see cref="PeopleRepository"/> class.
/// </remarks>
public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup) : IPeopleRepository
public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, IItemTypeLookup itemTypeLookup, IItemQueryHelpers queryHelpers) : IPeopleRepository
{
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider = dbProvider;
@@ -33,12 +34,13 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
{
using var context = _dbProvider.CreateDbContext();
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter);
int? distinctNameCount = null;
// Include PeopleBaseItemMap
if (!filter.ItemId.IsEmpty())
{
dbQuery = dbQuery.Include(p => p.BaseItems!.Where(m => m.ItemId == filter.ItemId))
.OrderBy(e => e.BaseItems!.First(e => e.ItemId == filter.ItemId).ListOrder)
.OrderBy(e => e.BaseItems!.Where(m => m.ItemId == filter.ItemId).Min(m => m.ListOrder))
.ThenBy(e => e.PersonType)
.ThenBy(e => e.Name);
}
@@ -46,17 +48,25 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
{
// The Peoples table has one row per (Name, PersonType), so the same person can
// appear multiple times (e.g. as Actor and GuestStar). Collapse to one row per
// name so /Persons doesn't return the same BaseItem id repeatedly. Lowercase the
// grouping key so case-only duplicates collapse together.
var representativeIds = dbQuery
.GroupBy(e => e.Name.ToLower())
.Select(g => g.Min(e => e.Id));
dbQuery = context.Peoples.AsNoTracking()
.Where(p => representativeIds.Contains(p.Id))
.OrderBy(e => e.Name);
// name so /Persons doesn't return the same BaseItem id repeatedly, keeping the
// lowest id per lowercased name so case-only duplicates collapse together.
var candidates = dbQuery;
dbQuery = candidates
.Where(p => !candidates.Any(other => other.Name.ToLower() == p.Name.ToLower() && other.Id < p.Id))
.OrderBy(e => e.Name.ToLower());
if (filter.EnableTotalRecordCount)
{
distinctNameCount = candidates.Select(e => e.Name.ToLower()).Distinct().Count();
}
}
var count = 0;
if (filter.EnableTotalRecordCount)
{
count = distinctNameCount ?? dbQuery.Count();
}
var count = dbQuery.Count();
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
{
dbQuery = dbQuery.Skip(filter.StartIndex.Value);
@@ -71,7 +81,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
{
StartIndex = filter.StartIndex ?? 0,
TotalRecordCount = count,
Items = dbQuery.AsEnumerable().Select(Map).ToArray(),
Items = dbQuery.AsEnumerable().SelectMany(MapCredits).ToArray(),
};
}
@@ -79,7 +89,11 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
public IReadOnlyList<string> GetPeopleNames(InternalPeopleQuery filter)
{
using var context = _dbProvider.CreateDbContext();
var dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter).Select(e => e.Name).Distinct();
IQueryable<string> dbQuery = TranslateQuery(context.Peoples.AsNoTracking(), context, filter)
.Select(e => e.Name)
.Distinct()
.OrderBy(e => e);
if (filter.StartIndex.HasValue && filter.StartIndex > 0)
{
@@ -88,7 +102,7 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.Limit > 0)
{
dbQuery = dbQuery.OrderBy(e => e).Take(filter.Limit);
dbQuery = dbQuery.Take(filter.Limit);
}
return dbQuery.ToArray();
@@ -103,9 +117,17 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
person.Role = person.Role?.Trim() ?? string.Empty;
}
// multiple metadata providers can provide the _same_ person; dedupe case-insensitively.
people = people.DistinctBy(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
var personKeys = people.Select(e => e.Name.ToLowerInvariant() + "-" + e.Type).ToArray();
// Project the values every comparison below needs once, so neither the case folding nor the
// enum formatting is repeated per candidate.
var credits = people.Select(e => (Person: e, LoweredName: e.Name.ToLowerInvariant(), PersonType: e.Type.ToString(), LoweredRole: e.Role.ToLowerInvariant()));
// multiple metadata providers can provide the _same_ credit; dedupe case-insensitively.
// The role is part of the key because one person can hold several credits of the same type
// on an item, e.g. a Writer credited for both the Novel and the Screenplay.
var distinctCredits = credits.DistinctBy(e => (e.LoweredName, e.PersonType, e.LoweredRole)).ToArray();
var distinctPersons = distinctCredits.DistinctBy(e => (e.LoweredName, e.PersonType)).ToArray();
var personKeys = distinctPersons.Select(e => e.LoweredName + "-" + e.PersonType).ToArray();
using var context = _dbProvider.CreateDbContext();
using var transaction = context.Database.BeginTransaction();
@@ -118,23 +140,44 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
.Select(f => f.item)
.ToArray();
var toAdd = people
.Where(e => !existingPersons.Any(f => string.Equals(f.Name, e.Name, StringComparison.OrdinalIgnoreCase) && f.PersonType == e.Type.ToString()))
.Select(Map);
var existingPersonKeys = existingPersons.Select(e => (e.Name.ToLowerInvariant(), e.PersonType ?? string.Empty)).ToHashSet();
var toAdd = distinctPersons
.Where(e => !existingPersonKeys.Contains((e.LoweredName, e.PersonType)))
.Select(e => Map(e.Person))
.ToArray();
context.Peoples.AddRange(toAdd);
context.SaveChanges();
var personsEntities = toAdd.Concat(existingPersons).ToArray();
// The Peoples table can hold case-only duplicates, so keep the first match per key just as
// the previous First() lookup did.
var personsEntities = new Dictionary<(string LoweredName, string PersonType), People>();
foreach (var entity in toAdd.Concat(existingPersons))
{
personsEntities.TryAdd((entity.Name.ToLowerInvariant(), entity.PersonType ?? string.Empty), entity);
}
var existingMaps = context.PeopleBaseItemMap.Include(e => e.People).Where(e => e.ItemId == itemId).ToList();
var existingMapsByCredit = new Dictionary<(string LoweredName, string PersonType, string LoweredRole), PeopleBaseItemMap>();
foreach (var map in existingMaps)
{
existingMapsByCredit.TryAdd((map.People.Name.ToLowerInvariant(), map.People.PersonType ?? string.Empty, map.Role?.ToLowerInvariant() ?? string.Empty), map);
}
var listOrder = 0;
foreach (var person in people)
foreach (var credit in distinctCredits)
{
var entityPerson = personsEntities.First(e => string.Equals(e.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.PersonType == person.Type.ToString());
var existingMap = existingMaps.FirstOrDefault(e => string.Equals(e.People.Name, person.Name, StringComparison.OrdinalIgnoreCase) && e.People.PersonType == person.Type.ToString() && e.Role == person.Role);
if (existingMap is null)
var entityPerson = personsEntities[(credit.LoweredName, credit.PersonType)];
if (existingMapsByCredit.TryGetValue((credit.LoweredName, credit.PersonType, credit.LoweredRole), out var existingMap))
{
// Update the order for existing mappings
existingMap.ListOrder = listOrder;
existingMap.SortOrder = credit.Person.SortOrder;
// person mapping already exists so remove from list
existingMaps.Remove(existingMap);
}
else
{
context.PeopleBaseItemMap.Add(new PeopleBaseItemMap()
{
@@ -143,18 +186,10 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
People = null!,
PeopleId = entityPerson.Id,
ListOrder = listOrder,
SortOrder = person.SortOrder,
Role = person.Role
SortOrder = credit.Person.SortOrder,
Role = credit.Person.Role
});
}
else
{
// Update the order for existing mappings
existingMap.ListOrder = listOrder;
existingMap.SortOrder = person.SortOrder;
// person mapping already exists so remove from list
existingMaps.Remove(existingMap);
}
listOrder++;
}
@@ -201,9 +236,66 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
return result;
}
private PersonInfo Map(People people)
/// <inheritdoc/>
public IReadOnlyDictionary<Guid, IReadOnlyList<PersonInfo>> GetPeopleByItems(IReadOnlyList<Guid> itemIds)
{
using var context = _dbProvider.CreateDbContext();
var rows = context.PeopleBaseItemMap
.AsNoTracking()
.Where(m => itemIds.Contains(m.ItemId))
.OrderBy(m => m.ListOrder)
.Select(m => new
{
m.ItemId,
m.Role,
m.SortOrder,
m.People.Id,
m.People.Name,
m.People.PersonType
})
.ToList();
var result = new Dictionary<Guid, IReadOnlyList<PersonInfo>>();
foreach (var group in rows.GroupBy(r => r.ItemId))
{
var people = new List<PersonInfo>();
foreach (var row in group)
{
var personInfo = new PersonInfo
{
ItemId = row.ItemId,
Id = row.Id,
Name = row.Name,
Role = row.Role,
SortOrder = row.SortOrder
};
if (Enum.TryParse<PersonKind>(row.PersonType, out var kind))
{
personInfo.Type = kind;
}
people.Add(personInfo);
}
result[group.Key] = people;
}
return result;
}
private IEnumerable<PersonInfo> MapCredits(People people)
{
var mappings = people.BaseItems;
if (mappings is null || mappings.Count == 0)
{
return [Map(people, null)];
}
return mappings.OrderBy(m => m.ListOrder).Select(m => Map(people, m));
}
private PersonInfo Map(People people, PeopleBaseItemMap? mapping)
{
var mapping = people.BaseItems?.FirstOrDefault();
var personInfo = new PersonInfo()
{
Id = people.Id,
@@ -236,13 +328,25 @@ public class PeopleRepository(IDbContextFactory<JellyfinDbContext> dbProvider, I
if (filter.User is not null && filter.IsFavorite.HasValue)
{
var personType = itemTypeLookup.BaseItemKindNames[BaseItemKind.Person];
var oldQuery = query;
var userId = filter.User.Id;
var isFavorite = filter.IsFavorite.Value;
var favoriteItemIds = context.UserData
.Where(u => u.UserId.Equals(userId) && u.IsFavorite == isFavorite)
.Select(u => u.ItemId);
query = context.UserData
.Where(u => u.Item!.Type == personType && u.IsFavorite == filter.IsFavorite && u.UserId.Equals(filter.User.Id))
.Join(oldQuery, e => e.Item!.Name, e => e.Name, (item, person) => person)
.Distinct()
.AsNoTracking();
var favoriteNames = context.BaseItems
.Where(b => b.Type == personType && favoriteItemIds.Contains(b.Id))
.Select(b => b.Name);
query = query.Where(e => favoriteNames.Contains(e.Name));
}
if (filter.AccessFilter is not null)
{
// Keep only people credited on at least one item the user can see.
var accessibleItems = queryHelpers.ApplyAccessFiltering(context, context.BaseItems.AsNoTracking(), filter.AccessFilter);
query = query.Where(e => context.PeopleBaseItemMap
.Any(m => m.PeopleId == e.Id && accessibleItems.Any(i => i.Id == m.ItemId)));
}
if (!filter.ItemId.IsEmpty())
@@ -87,8 +87,9 @@ public static class StorageHelper
/// </summary>
private static string ResolvePath(string path)
{
var parts = path.Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
var current = Path.DirectorySeparatorChar.ToString();
var root = Path.GetPathRoot(path) ?? Path.DirectorySeparatorChar.ToString();
var parts = path.Substring(root.Length).Split(Path.DirectorySeparatorChar, StringSplitOptions.RemoveEmptyEntries);
var current = root;
foreach (var part in parts)
{
current = Path.Combine(current, part);
@@ -193,10 +193,15 @@ internal class JellyfinMigrationService
{
var historyRepository = dbContext.GetService<IHistoryRepository>();
var migrationsAssembly = dbContext.GetService<IMigrationsAssembly>();
(string Key, IInternalMigration Migration)[] migrations = [];
var completedMigrations = 0;
string? lastMigrationKey = null;
do
{ // migrations may alter the migration state. Reevaluate the applicable migrations after every stage ran until there are no more to apply.
while (true)
{
// A single migration can change which migrations still apply: IMigrator.MigrateAsync treats its argument as the
// state to end up in, so it reverts everything applied after it, and a reverted migration can take code migrations
// with it (AddNormalizedUsername.Down drops the UpdateNormalizedUsername history row). Anything computed before
// that point is stale, so only ever run the next migration and then work out the pending set again.
var appliedMigrations = await historyRepository.GetAppliedMigrationsAsync().ConfigureAwait(false);
var pendingCodeMigrations = migrationStage
.Where(e => appliedMigrations.All(f => f.MigrationId != e.BuildCodeMigrationId()))
@@ -212,73 +217,86 @@ internal class JellyfinMigrationService
}
(string Key, IInternalMigration Migration)[] pendingMigrations = [.. pendingCodeMigrations, .. pendingDatabaseMigrations];
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)
if (pendingMigrations.Length == 0)
{
// 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
{
migrationLogger.LogInformation("Perform migration {Name}", item.Key);
await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
}
catch (Exception ex)
{
migrationLogger.LogCritical("Error: {Error}", ex.Message);
migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
break;
}
if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
if (completedMigrations == 0)
{
logger.LogInformation("There are {Pending} migrations for stage {Stage}.", pendingMigrations.Length, stage);
}
var item = pendingMigrations.OrderBy(e => e.Key, StringComparer.Ordinal).First();
if (string.Equals(item.Key, lastMigrationKey, StringComparison.Ordinal))
{
throw new InvalidOperationException($"Migration {item.Key} ran but did not record itself as applied and would repeat indefinitely.");
}
lastMigrationKey = item.Key;
// Surface generic "Running migration X of Y" progress in the always-visible startup UI header.
SetupServer.ReportActivity(StartupActivity.Migration(completedMigrations + 1, completedMigrations + pendingMigrations.Length));
var migrationLogger = logger.With(_loggerFactory.CreateLogger(item.Migration.GetType().Name)).BeginGroup($"{item.Key}");
try
{
migrationLogger.LogInformation("Perform migration {Name}", item.Key);
await item.Migration.PerformAsync(migrationLogger).ConfigureAwait(false);
migrationLogger.LogInformation("Migration {Name} was successfully applied", item.Key);
}
catch (Exception ex)
{
migrationLogger.LogCritical("Error: {Error}", ex.Message);
migrationLogger.LogError(ex, "Migration {Name} failed", item.Key);
if (_backupKey != default && _backupService is not null && _jellyfinDatabaseProvider is not null)
{
if (_backupKey.LibraryDb is not null)
{
if (_backupKey.LibraryDb is not null)
migrationLogger.LogInformation("Attempt to rollback librarydb.");
try
{
migrationLogger.LogInformation("Attempt to rollback librarydb.");
try
{
var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
File.Move(_backupKey.LibraryDb, libraryDbPath, true);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
}
var libraryDbPath = Path.Combine(_applicationPaths.DataPath, DbFilename);
File.Move(_backupKey.LibraryDb, libraryDbPath, true);
}
if (_backupKey.JellyfinDb is not null)
catch (Exception inner)
{
migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
try
{
await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
}
}
if (_backupKey.FullBackup is not null)
{
migrationLogger.LogInformation("Attempt to rollback from backup.");
try
{
await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
}
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.LibraryDb);
}
}
throw;
if (_backupKey.JellyfinDb is not null)
{
migrationLogger.LogInformation("Attempt to rollback JellyfinDb.");
try
{
await _jellyfinDatabaseProvider.RestoreBackupFast(_backupKey.JellyfinDb, CancellationToken.None).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback {LibraryPath}. Manual intervention might be required to restore a operational state.", _backupKey.JellyfinDb);
}
}
if (_backupKey.FullBackup is not null)
{
migrationLogger.LogInformation("Attempt to rollback from backup.");
try
{
await _backupService.RestoreBackupAsync(_backupKey.FullBackup.Path).ConfigureAwait(false);
}
catch (Exception inner)
{
migrationLogger.LogCritical(inner, "Could not rollback from backup {Backup}. Manual intervention might be required to restore a operational state.", _backupKey.FullBackup.Path);
}
}
}
throw;
}
} while (migrations.Length != 0);
completedMigrations++;
}
}
}
@@ -1,61 +0,0 @@
using System;
using System.Linq;
using System.Threading;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Playlists;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Remove duplicate playlist entries.
/// </summary>
#pragma warning disable CS0618 // Type or member is obsolete
[JellyfinMigration("2025-04-20T19:00:00", nameof(RemoveDuplicatePlaylistChildren), "96C156A2-7A13-4B3B-A8B8-FB80C94D20C0")]
internal class RemoveDuplicatePlaylistChildren : IMigrationRoutine
#pragma warning restore CS0618 // Type or member is obsolete
{
private readonly ILibraryManager _libraryManager;
private readonly IPlaylistManager _playlistManager;
public RemoveDuplicatePlaylistChildren(
ILibraryManager libraryManager,
IPlaylistManager playlistManager)
{
_libraryManager = libraryManager;
_playlistManager = playlistManager;
}
/// <inheritdoc/>
public void Perform()
{
var playlists = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Playlist]
})
.Cast<Playlist>()
.Where(p => !p.OpenAccess || !p.OwnerUserId.Equals(Guid.Empty))
.ToArray();
if (playlists.Length > 0)
{
foreach (var playlist in playlists)
{
var linkedChildren = playlist.LinkedChildren;
if (linkedChildren.Length > 0)
{
var newLinkedChildren = linkedChildren
.Where(c => c.ItemId is null || c.ItemId.Value.Equals(Guid.Empty))
.Concat(linkedChildren
.Where(c => c.ItemId.HasValue && !c.ItemId.Value.Equals(Guid.Empty))
.DistinctBy(c => c.ItemId))
.ToArray();
playlist.LinkedChildren = newLinkedChildren;
playlist.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).GetAwaiter().GetResult();
_playlistManager.SavePlaylistFile(playlist);
}
}
}
}
}
@@ -63,7 +63,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
var itemsWithData = context.BaseItems
.Where(b => b.Data != null && (containerTypes.Contains(b.Type) || videoTypes.Contains(b.Type)))
.Select(b => new { b.Id, b.Data, b.Type })
.Select(b => new { b.Id, b.Data, b.Type, b.Path, b.IsFolder })
.ToList();
_logger.LogInformation("Found {Count} potential items with LinkedChildren data to process.", itemsWithData.Count);
@@ -74,6 +74,15 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
.GroupBy(b => b.Path!)
.ToDictionary(g => g.Key, g => g.First().Id);
// Needed to tell a stale cached ItemId apart from one that still points at a real item.
var allItemIds = context.BaseItems.Select(b => b.Id).ToHashSet();
var playlistParentIds = itemsWithData
.Where(b => b.Type == "MediaBrowser.Controller.Playlists.Playlist")
.Select(b => b.Id)
.ToHashSet();
var droppedChildren = 0;
var linkedChildrenToAdd = new List<LinkedChildEntity>();
var processedCount = 0;
const int progressLogStep = 1000;
@@ -100,7 +109,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
// Handle Video alternate versions
if (isVideo)
{
ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, linkedChildrenToAdd);
ProcessVideoAlternateVersions(doc.RootElement, item.Id, pathToIdMap, allItemIds, linkedChildrenToAdd);
}
// Handle LinkedChildren (for containers and other items)
@@ -110,46 +119,22 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
continue;
}
var isPlaylist = item.Type == "MediaBrowser.Controller.Playlists.Playlist";
// Legacy entries may hold a path relative to the container that holds them, so the
// container's own location has to be a real path, not a virtual one.
var itemPath = item.Path is null ? null : _appHost.ExpandVirtualPath(item.Path);
var containingFolderPath = item.IsFolder ? itemPath : Path.GetDirectoryName(itemPath);
var sortOrder = 0;
foreach (var childElement in linkedChildrenElement.EnumerateArray())
{
Guid? childId = null;
if (childElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null)
{
var itemIdStr = itemIdProp.GetString();
if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
{
childId = parsedId;
}
}
if (!childId.HasValue || childId.Value.IsEmpty())
{
if (childElement.TryGetProperty("Path", out var pathProp))
{
var path = pathProp.GetString();
if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
{
childId = resolvedId;
}
}
}
if (!childId.HasValue || childId.Value.IsEmpty())
{
if (childElement.TryGetProperty("LibraryItemId", out var libIdProp))
{
var libIdStr = libIdProp.GetString();
if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
{
childId = parsedLibId;
}
}
}
if (!childId.HasValue || childId.Value.IsEmpty())
var childId = ResolveChildId(childElement, containingFolderPath, pathToIdMap, allItemIds);
if (!childId.HasValue)
{
droppedChildren++;
_logger.LogWarning(
"Dropping unresolvable LinkedChild of {ParentId}: ItemId {ItemId}, path {ChildPath}",
item.Id,
GetStringProperty(childElement, "ItemId") ?? "none",
GetStringProperty(childElement, "Path") ?? "none");
continue;
}
@@ -175,7 +160,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
ParentId = item.Id,
ChildId = childId.Value,
ChildType = childType,
SortOrder = isPlaylist ? sortOrder : null
SortOrder = sortOrder
});
sortOrder++;
@@ -197,23 +182,37 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
.Select(lc => new { lc.ParentId, lc.ChildId })
.ToHashSet();
// A playlist may list the same child more than once, so it cannot be keyed by
// (ParentId, ChildId): skip a playlist wholesale if it already has rows instead, which
// keeps the routine re-runnable without collapsing repeated entries.
var populatedParentIds = context.LinkedChildren
.Select(lc => lc.ParentId)
.Distinct()
.ToHashSet();
var toInsert = linkedChildrenToAdd
.Where(lc => !existingKeys.Contains(new { lc.ParentId, lc.ChildId }))
.Where(lc => playlistParentIds.Contains(lc.ParentId)
? !populatedParentIds.Contains(lc.ParentId)
: !existingKeys.Contains(new { lc.ParentId, lc.ChildId }))
.ToList();
if (toInsert.Count > 0)
{
// Deduplicate by composite key (ParentId, ChildId)
// Every container type other than a playlist keeps a single entry per child.
// Priority: LocalAlternateVersion > LinkedAlternateVersion > Other
toInsert = toInsert
.OrderBy(lc => lc.ChildType switch
{
LinkedChildType.LocalAlternateVersion => 0,
LinkedChildType.LinkedAlternateVersion => 1,
_ => 2
})
.DistinctBy(lc => new { lc.ParentId, lc.ChildId })
.ToList();
toInsert =
[
.. toInsert.Where(lc => playlistParentIds.Contains(lc.ParentId)),
.. toInsert
.Where(lc => !playlistParentIds.Contains(lc.ParentId))
.OrderBy(lc => lc.ChildType switch
{
LinkedChildType.LocalAlternateVersion => 0,
LinkedChildType.LinkedAlternateVersion => 1,
_ => 2
})
.DistinctBy(lc => new { lc.ParentId, lc.ChildId })
];
var childIds = toInsert.Select(lc => lc.ChildId).Distinct().ToList();
var existingChildIds = context.BaseItems
@@ -267,7 +266,10 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
_logger.LogInformation("No LinkedChildren data found to migrate.");
}
_logger.LogInformation("LinkedChildren migration completed. Processed {Count} items.", processedCount);
_logger.LogInformation(
"LinkedChildren migration completed. Processed {Count} items, dropped {DroppedCount} unresolvable children.",
processedCount,
droppedChildren);
CleanupWrongTypeAlternateVersions(context);
CleanupOrphanedAlternateVersionBaseItems(context);
@@ -418,6 +420,12 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
var internalMetadataPath = _appPaths.InternalMetadataPath;
// An item outside every library location is normally left over from a removed media path, but
// it looks exactly the same as one whose storage failed to mount (a wrong bind mount on the
// first container start, for example). Only act on it while every location is readable.
var canRemoveUnrootedItems = inaccessiblePaths.Count == 0;
var skippedUnrootedItems = 0;
var staleIds = new List<Guid>();
foreach (var item in itemsWithPaths)
{
@@ -436,6 +444,7 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
// Directory check covers BDMV/DVD items whose Path points to a folder
if (!File.Exists(path) && !Directory.Exists(path))
{
_logger.LogDebug("Removing item {ItemId}: file {Path} no longer exists.", item.Id, path);
staleIds.Add(item.Id);
}
}
@@ -443,12 +452,28 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
{
// Item is not under ANY library location (accessible or not) —
// it's orphaned from all libraries (e.g. media path was removed from config)
staleIds.Add(item.Id);
if (canRemoveUnrootedItems)
{
_logger.LogDebug("Removing item {ItemId}: path {Path} is outside every library location.", item.Id, path);
staleIds.Add(item.Id);
}
else
{
skippedUnrootedItems++;
}
}
// Otherwise: item is under an inaccessible location — skip (storage may be offline)
}
if (skippedUnrootedItems > 0)
{
_logger.LogWarning(
"Keeping {Count} items that are outside every library location because {LocationCount} library location(s) are currently unavailable.",
skippedUnrootedItems,
inaccessiblePaths.Count);
}
if (staleIds.Count == 0)
{
_logger.LogInformation("No stale items found.");
@@ -518,18 +543,86 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
orphanedLinkedChildren.AddRange(orphanedByParent);
}
// Remove all orphaned records
var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.ChildId }).ToList();
// Remove all orphaned records. Both queries can return the same row, and a playlist may hold
// several rows for one child, so the position is what identifies an entry here.
var distinctOrphaned = orphanedLinkedChildren.DistinctBy(lc => new { lc.ParentId, lc.SortOrder }).ToList();
context.LinkedChildren.RemoveRange(distinctOrphaned);
context.SaveChanges();
_logger.LogInformation("Successfully removed {Count} orphaned LinkedChildren records.", distinctOrphaned.Count);
}
/// <summary>
/// Resolves the item a legacy LinkedChild entry points at.
/// </summary>
private static Guid? ResolveChildId(
JsonElement childElement,
string? containingFolderPath,
Dictionary<string, Guid> pathToIdMap,
HashSet<Guid> allItemIds)
{
// Pre-12 data only cached ItemId and re-resolved it from the path whenever the cached value
// went stale (BaseItem.GetLinkedChild in 10.x). An id that no longer exists must therefore
// fall through to the path, or the entry is lost even though its file is still in the library.
if (TryGetGuidProperty(childElement, "ItemId", out var itemId) && allItemIds.Contains(itemId))
{
return itemId;
}
var path = GetStringProperty(childElement, "Path");
if (!string.IsNullOrEmpty(path))
{
if (pathToIdMap.TryGetValue(path, out var idByPath))
{
return idByPath;
}
// 10.x resolved entries relative to the container that holds them.
if (!Path.IsPathRooted(path) && !string.IsNullOrEmpty(containingFolderPath))
{
string? absolutePath = null;
try
{
absolutePath = Path.GetFullPath(Path.Combine(containingFolderPath, path));
}
catch (ArgumentException)
{
// Malformed path, nothing to resolve.
}
if (absolutePath is not null && pathToIdMap.TryGetValue(absolutePath, out var idByAbsolutePath))
{
return idByAbsolutePath;
}
}
}
if (TryGetGuidProperty(childElement, "LibraryItemId", out var libraryItemId) && allItemIds.Contains(libraryItemId))
{
return libraryItemId;
}
return null;
}
private static string? GetStringProperty(JsonElement element, string propertyName)
=> element.TryGetProperty(propertyName, out var property) && property.ValueKind == JsonValueKind.String
? property.GetString()
: null;
private static bool TryGetGuidProperty(JsonElement element, string propertyName, out Guid value)
{
value = Guid.Empty;
var raw = GetStringProperty(element, propertyName);
return !string.IsNullOrEmpty(raw) && Guid.TryParse(raw, out value) && !value.IsEmpty();
}
private void ProcessVideoAlternateVersions(
JsonElement root,
Guid parentId,
Dictionary<string, Guid> pathToIdMap,
HashSet<Guid> allItemIds,
List<LinkedChildEntity> linkedChildrenToAdd)
{
int sortOrder = 0;
@@ -582,45 +675,8 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
{
foreach (var linkedChildElement in linkedAlternateVersionsElement.EnumerateArray())
{
Guid? childId = null;
// Try to get ItemId
if (linkedChildElement.TryGetProperty("ItemId", out var itemIdProp) && itemIdProp.ValueKind != JsonValueKind.Null)
{
var itemIdStr = itemIdProp.GetString();
if (!string.IsNullOrEmpty(itemIdStr) && Guid.TryParse(itemIdStr, out var parsedId))
{
childId = parsedId;
}
}
// Try to get from Path if ItemId not available
if (!childId.HasValue || childId.Value.IsEmpty())
{
if (linkedChildElement.TryGetProperty("Path", out var pathProp))
{
var path = pathProp.GetString();
if (!string.IsNullOrEmpty(path) && pathToIdMap.TryGetValue(path, out var resolvedId))
{
childId = resolvedId;
}
}
}
// Try LibraryItemId as fallback
if (!childId.HasValue || childId.Value.IsEmpty())
{
if (linkedChildElement.TryGetProperty("LibraryItemId", out var libIdProp))
{
var libIdStr = libIdProp.GetString();
if (!string.IsNullOrEmpty(libIdStr) && Guid.TryParse(libIdStr, out var parsedLibId))
{
childId = parsedLibId;
}
}
}
if (!childId.HasValue || childId.Value.IsEmpty())
var childId = ResolveChildId(linkedChildElement, null, pathToIdMap, allItemIds);
if (!childId.HasValue)
{
_logger.LogWarning("Could not resolve LinkedAlternateVersion child ID for parent {ParentId}", parentId);
continue;
@@ -0,0 +1,113 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Database.Implementations;
using Jellyfin.Extensions;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Migration to recompute the SortName of all items that have a forced sort name.
/// </summary>
[JellyfinMigration("2026-07-22T12:00:00", nameof(RefreshForcedSortNames))]
[JellyfinMigrationBackup(JellyfinDb = true)]
public class RefreshForcedSortNames : IAsyncMigrationRoutine
{
private readonly IStartupLogger<RefreshForcedSortNames> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IServerConfigurationManager _configurationManager;
/// <summary>
/// Initializes a new instance of the <see cref="RefreshForcedSortNames"/> class.
/// </summary>
/// <param name="logger">The logger.</param>
/// <param name="dbProvider">Instance of the <see cref="IDbContextFactory{JellyfinDbContext}"/> interface.</param>
/// <param name="configurationManager">The server configuration manager providing the sort rules.</param>
public RefreshForcedSortNames(
IStartupLogger<RefreshForcedSortNames> logger,
IDbContextFactory<JellyfinDbContext> dbProvider,
IServerConfigurationManager configurationManager)
{
_logger = logger;
_dbProvider = dbProvider;
_configurationManager = configurationManager;
}
/// <inheritdoc />
public async Task PerformAsync(CancellationToken cancellationToken)
{
const int Limit = 10000;
int itemCount = 0;
var configuration = _configurationManager.Configuration;
// Only the Person type disables alphanumeric sorting; everything else uses the cleaning rules.
var personType = typeof(Person).ToString();
var sw = Stopwatch.StartNew();
using var context = _dbProvider.CreateDbContext();
var records = context.BaseItems.Count(b => !string.IsNullOrEmpty(b.ForcedSortName));
_logger.LogInformation("Refreshing SortName for {Count} library items with a forced sort name", records);
var processedInPartition = 0;
await foreach (var item in context.BaseItems
.Where(b => !string.IsNullOrEmpty(b.ForcedSortName))
.OrderBy(e => e.Id)
.WithPartitionProgress((partition) => _logger.LogInformation("Processed: {Offset}/{Total} - Updated: {UpdatedCount} - Time: {Elapsed}", partition * Limit, records, itemCount, sw.Elapsed))
.PartitionEagerAsync(Limit, cancellationToken)
.WithCancellation(cancellationToken)
.ConfigureAwait(false))
{
try
{
var enableAlphaNumericSorting = !string.Equals(item.Type, personType, StringComparison.Ordinal);
var newSortName = BaseItem.GetSortName(item.ForcedSortName!, enableAlphaNumericSorting, configuration);
if (!string.Equals(newSortName, item.SortName, StringComparison.Ordinal))
{
_logger.LogDebug(
"Updating SortName for item {Id}: '{OldValue}' -> '{NewValue}'",
item.Id,
item.SortName,
newSortName);
item.SortName = newSortName;
itemCount++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update SortName for item {Id} ({Name})", item.Id, item.Name);
}
processedInPartition++;
if (processedInPartition >= Limit)
{
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
// Clear tracked entities to avoid memory growth across partitions
context.ChangeTracker.Clear();
processedInPartition = 0;
}
}
// Save any remaining changes after the loop
if (processedInPartition > 0)
{
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
context.ChangeTracker.Clear();
}
_logger.LogInformation(
"Refreshed SortName for {UpdatedCount} out of {TotalCount} items in {Time}",
itemCount,
records,
sw.Elapsed);
}
}
@@ -0,0 +1,102 @@
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using Jellyfin.Database.Implementations;
using Jellyfin.Server.ServerSetupApp;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Recomputes the presentation unique key for every series so existing items adopt the folder-set-free key format.
/// </summary>
[JellyfinMigration("2026-07-23T12:00:00", nameof(RecomputeSeriesPresentationKey))]
[JellyfinMigrationBackup(JellyfinDb = true)]
internal class RecomputeSeriesPresentationKey : IAsyncMigrationRoutine
{
private readonly IStartupLogger<RecomputeSeriesPresentationKey> _logger;
private readonly ILibraryManager _libraryManager;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
/// <summary>
/// Initializes a new instance of the <see cref="RecomputeSeriesPresentationKey"/> class.
/// </summary>
/// <param name="logger">The startup logger.</param>
/// <param name="libraryManager">The library manager.</param>
/// <param name="dbProvider">The database context factory.</param>
public RecomputeSeriesPresentationKey(
IStartupLogger<RecomputeSeriesPresentationKey> logger,
ILibraryManager libraryManager,
IDbContextFactory<JellyfinDbContext> dbProvider)
{
_logger = logger;
_libraryManager = libraryManager;
_dbProvider = dbProvider;
}
/// <inheritdoc />
public async Task PerformAsync(CancellationToken cancellationToken)
{
var series = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Series]
}).OfType<Series>().ToArray();
_logger.LogInformation("Recomputing presentation unique key for {Count} series", series.Length);
const int ProgressInterval = 250;
var sw = Stopwatch.StartNew();
var processed = 0;
var updated = 0;
var dbContext = await _dbProvider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false);
await using (dbContext.ConfigureAwait(false))
{
foreach (var item in series)
{
cancellationToken.ThrowIfCancellationRequested();
if (++processed % ProgressInterval == 0)
{
_logger.LogInformation("Processed {Processed}/{Total} series - Updated: {Updated} - Time: {Elapsed}", processed, series.Length, updated, sw.Elapsed);
}
var oldKey = item.PresentationUniqueKey;
var newKey = item.CreatePresentationUniqueKey();
if (string.Equals(oldKey, newKey, StringComparison.Ordinal))
{
continue;
}
// Write only the changed column instead of re-persisting the whole item.
var id = item.Id;
await dbContext.BaseItems
.Where(e => e.Id.Equals(id))
.ExecuteUpdateAsync(e => e.SetProperty(f => f.PresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);
// Seasons and episodes cache the series key in SeriesPresentationUniqueKey and are matched
// to the series by it. Re-point every child still carrying the old key in a single set-based
// update so they stay attached without waiting for the next scan.
if (!string.IsNullOrEmpty(oldKey))
{
await dbContext.BaseItems
.Where(e => e.SeriesPresentationUniqueKey == oldKey)
.ExecuteUpdateAsync(e => e.SetProperty(f => f.SeriesPresentationUniqueKey, newKey), cancellationToken)
.ConfigureAwait(false);
}
updated++;
}
}
_logger.LogInformation("Recomputed presentation unique key for {Updated} of {Count} series in {Elapsed}", updated, series.Length, sw.Elapsed);
}
}
@@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Server.Migrations.Routines;
/// <summary>
/// Restores playlist entries from playlist.xml for playlists that lost all of their children.
/// </summary>
[JellyfinMigration("2026-07-29T12:00:00", nameof(RestorePlaylistChildrenFromMetadata))]
internal class RestorePlaylistChildrenFromMetadata : IDatabaseMigrationRoutine
{
private const string PlaylistTypeName = "MediaBrowser.Controller.Playlists.Playlist";
private const string PlaylistFileName = "playlist.xml";
private readonly ILogger<RestorePlaylistChildrenFromMetadata> _logger;
private readonly IDbContextFactory<JellyfinDbContext> _dbProvider;
private readonly IServerApplicationHost _appHost;
public RestorePlaylistChildrenFromMetadata(
ILoggerFactory loggerFactory,
IDbContextFactory<JellyfinDbContext> dbProvider,
IServerApplicationHost appHost)
{
_logger = loggerFactory.CreateLogger<RestorePlaylistChildrenFromMetadata>();
_dbProvider = dbProvider;
_appHost = appHost;
}
/// <inheritdoc/>
public void Perform()
{
using var context = _dbProvider.CreateDbContext();
var playlists = context.BaseItems
.Where(b => b.Type == PlaylistTypeName && b.Path != null)
.Select(b => new { b.Id, b.Name, b.Path })
.ToList();
if (playlists.Count == 0)
{
return;
}
var childCountByPlaylist = context.LinkedChildren
.Where(lc => context.BaseItems.Any(b => b.Id.Equals(lc.ParentId) && b.Type == PlaylistTypeName))
.GroupBy(lc => lc.ParentId)
.Select(g => new { ParentId = g.Key, Count = g.Count() })
.ToDictionary(g => g.ParentId, g => g.Count);
var pathToIdMap = context.BaseItems
.Where(b => b.Path != null)
.Select(b => new { b.Id, b.Path })
.GroupBy(b => b.Path!)
.ToDictionary(g => g.Key, g => g.First().Id);
var restoredPlaylists = 0;
var restoredEntries = 0;
foreach (var playlist in playlists)
{
// Only directory-based (Jellyfin-managed) playlists keep their entries in playlist.xml.
// A playlist that is itself a file (.m3u and friends) is re-read by the library scan.
var playlistPath = _appHost.ExpandVirtualPath(playlist.Path!);
var metadataPath = Path.Combine(playlistPath, PlaylistFileName);
if (!Directory.Exists(playlistPath) || !File.Exists(metadataPath))
{
continue;
}
var storedPaths = ReadEntryPaths(metadataPath, playlist.Id);
if (storedPaths.Count == 0)
{
continue;
}
var childCount = childCountByPlaylist.GetValueOrDefault(playlist.Id);
if (childCount > 0)
{
// Merging into a playlist that still has entries would resurrect anything the user
// removed while the metadata file was not rewritten, and there is no way to tell the
// two apart. Report the mismatch instead so it can be checked by hand.
if (storedPaths.Count > childCount)
{
_logger.LogWarning(
"Playlist {PlaylistName} ({PlaylistId}) holds {ChildCount} entries but {MetadataPath} lists {StoredCount}. Not restoring automatically.",
playlist.Name,
playlist.Id,
childCount,
metadataPath,
storedPaths.Count);
}
continue;
}
var sortOrder = 0;
foreach (var storedPath in storedPaths)
{
if (!pathToIdMap.TryGetValue(storedPath, out var childId))
{
_logger.LogWarning(
"Cannot restore entry {EntryPath} of playlist {PlaylistName}: no library item has that path.",
storedPath,
playlist.Name);
continue;
}
context.LinkedChildren.Add(new LinkedChildEntity
{
ParentId = playlist.Id,
ChildId = childId,
ChildType = LinkedChildType.Manual,
SortOrder = sortOrder
});
sortOrder++;
}
if (sortOrder > 0)
{
restoredPlaylists++;
restoredEntries += sortOrder;
_logger.LogInformation(
"Restored {Count} entries of empty playlist {PlaylistName} ({PlaylistId}) from {MetadataPath}.",
sortOrder,
playlist.Name,
playlist.Id,
metadataPath);
}
}
if (restoredEntries > 0)
{
context.SaveChanges();
_logger.LogInformation("Restored {EntryCount} entries across {PlaylistCount} playlists.", restoredEntries, restoredPlaylists);
}
}
private List<string> ReadEntryPaths(string metadataPath, Guid playlistId)
{
var paths = new List<string>();
var settings = new XmlReaderSettings
{
IgnoreComments = true,
IgnoreWhitespace = true,
IgnoreProcessingInstructions = true,
DtdProcessing = DtdProcessing.Prohibit
};
try
{
using var reader = XmlReader.Create(metadataPath, settings);
var inEntry = false;
while (reader.Read())
{
if (reader.NodeType != XmlNodeType.Element)
{
continue;
}
if (string.Equals(reader.Name, "PlaylistItem", StringComparison.Ordinal))
{
inEntry = true;
}
else if (inEntry && string.Equals(reader.Name, "Path", StringComparison.Ordinal))
{
inEntry = false;
var value = reader.ReadElementContentAsString();
if (!string.IsNullOrWhiteSpace(value))
{
paths.Add(value.Trim());
}
}
}
}
catch (Exception ex) when (ex is XmlException or IOException or UnauthorizedAccessException)
{
_logger.LogWarning(ex, "Could not read playlist metadata {MetadataPath} of playlist {PlaylistId}.", metadataPath, playlistId);
}
return paths;
}
}
+9 -1
View File
@@ -72,6 +72,14 @@ namespace MediaBrowser.Common.Plugins
/// </summary>
public bool IsEnabledAndSupported => _supported && Manifest.Status >= PluginStatus.Active;
/// <summary>
/// Gets or sets a value indicating whether a restart is required for the plugin's state to take effect.
/// </summary>
/// <remarks>
/// Memory only. <see cref="Manifest"/> holds the state that is persisted to disk.
/// </remarks>
public bool RestartRequired { get; set; }
/// <summary>
/// Gets a value indicating whether the plugin has a manifest.
/// </summary>
@@ -108,7 +116,7 @@ namespace MediaBrowser.Common.Plugins
public PluginInfo GetPluginInfo()
{
var inst = Instance?.GetPluginInfo() ?? new PluginInfo(Manifest.Name, Version, Manifest.Description, Manifest.Id, true);
inst.Status = Manifest.Status;
inst.Status = RestartRequired ? PluginStatus.Restart : Manifest.Status;
inst.HasImage = !string.IsNullOrEmpty(Manifest.ImagePath) || !string.IsNullOrEmpty(Manifest.ImageResourceName);
return inst;
}
@@ -11,7 +11,9 @@ namespace MediaBrowser.Controller.Drawing
// Determine the output size based on incoming parameters
var newSize = DrawingUtils.Resize(originalImageSize, options.Width ?? 0, options.Height ?? 0, options.MaxWidth ?? 0, options.MaxHeight ?? 0);
newSize = DrawingUtils.ResizeFill(newSize, options.FillWidth, options.FillHeight);
return newSize;
// Never encode larger than the source.
return DrawingUtils.ScaleDownToFit(newSize, originalImageSize);
}
}
}
+126 -19
View File
@@ -27,6 +27,7 @@ using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Controller.Persistence;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
@@ -87,7 +88,7 @@ namespace MediaBrowser.Controller.Entities
Model.Entities.ExtraType.Short
};
private static readonly char[] VersionDelimiters = ['-', '_', '.'];
private protected static readonly char[] VersionDelimiters = ['-', '_', '.'];
private string _sortName;
@@ -540,8 +541,8 @@ namespace MediaBrowser.Controller.Entities
{
if (!string.IsNullOrEmpty(ForcedSortName))
{
// Need the ToLower because that's what CreateSortName does
_sortName = ModifySortChunks(ForcedSortName).ToLowerInvariant();
// Run the forced sort name through the same cleaning as auto-generated sort names.
_sortName = GetSortName(ForcedSortName, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
}
else
{
@@ -770,6 +771,17 @@ namespace MediaBrowser.Controller.Entities
[JsonIgnore]
protected virtual bool SupportsOwnedItems => !ParentId.IsEmpty() && IsFileProtocol;
/// <summary>
/// Gets a value indicating whether this item searches the folder it lives in for its own extras.
/// </summary>
[JsonIgnore]
protected virtual bool SearchesContainingFolderForExtras =>
IsFileProtocol
&& SupportsOwnedItems
&& !IsInMixedFolder
&& this is not (ICollectionFolder or UserRootFolder or AggregateFolder)
&& GetType() != typeof(Folder);
[JsonIgnore]
public virtual bool SupportsPeople => false;
@@ -926,19 +938,31 @@ namespace MediaBrowser.Controller.Entities
/// <returns>System.String.</returns>
protected virtual string CreateSortName()
{
if (Name is null)
return GetSortName(Name, EnableAlphaNumericSorting, ConfigurationManager.Configuration);
}
/// <summary>
/// Cleans a raw name into its sortable form by applying the configured sort rules.
/// </summary>
/// <param name="name">The raw name to clean.</param>
/// <param name="enableAlphaNumericSorting">Whether alphanumeric sorting rules should be applied.</param>
/// <param name="configuration">The server configuration providing the sort rules.</param>
/// <returns>The cleaned, sortable name, or <c>null</c> if <paramref name="name"/> is <c>null</c>.</returns>
public static string GetSortName(string name, bool enableAlphaNumericSorting, ServerConfiguration configuration)
{
if (name is null)
{
return null; // some items may not have name filled in properly
}
if (!EnableAlphaNumericSorting)
if (!enableAlphaNumericSorting)
{
return Name.TrimStart();
return name.TrimStart();
}
var sortable = Name.Trim().ToLowerInvariant();
var sortable = name.Trim().ToLowerInvariant();
foreach (var search in ConfigurationManager.Configuration.SortRemoveWords)
foreach (var search in configuration.SortRemoveWords)
{
// Remove from beginning if a space follows
if (sortable.StartsWith(search + " ", StringComparison.Ordinal))
@@ -956,12 +980,12 @@ namespace MediaBrowser.Controller.Entities
}
}
foreach (var removeChar in ConfigurationManager.Configuration.SortRemoveCharacters)
foreach (var removeChar in configuration.SortRemoveCharacters)
{
sortable = sortable.Replace(removeChar, string.Empty, StringComparison.Ordinal);
}
foreach (var replaceChar in ConfigurationManager.Configuration.SortReplaceCharacters)
foreach (var replaceChar in configuration.SortReplaceCharacters)
{
sortable = sortable.Replace(replaceChar, " ", StringComparison.Ordinal);
}
@@ -1515,7 +1539,14 @@ namespace MediaBrowser.Controller.Entities
/// <returns><c>true</c> if any items have changed, else <c>false</c>.</returns>
protected virtual async Task<bool> RefreshedOwnedItems(MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
{
if (!IsFileProtocol || !SupportsOwnedItems || IsInMixedFolder || this is ICollectionFolder or UserRootFolder or AggregateFolder || this.GetType() == typeof(Folder))
if (!SearchesContainingFolderForExtras)
{
return false;
}
if (GetParent() is Folder container
&& container.SearchesContainingFolderForExtras
&& string.Equals(container.Path, ContainingFolderPath, StringComparison.OrdinalIgnoreCase))
{
return false;
}
@@ -1530,33 +1561,59 @@ namespace MediaBrowser.Controller.Entities
private async Task<bool> RefreshExtras(BaseItem item, MetadataRefreshOptions options, IReadOnlyList<FileSystemMetadata> fileSystemChildren, CancellationToken cancellationToken)
{
// An extra is owned by the version it is named after, so all of them are maintained together.
var currentExtras = LibraryManager.GetItemList(new InternalItemsQuery()
{
OwnerIds = item.GetOwnedVersionIds()
}).Where(e => e.ExtraType.HasValue).ToList();
var currentExtraIds = currentExtras.Select(e => e.Id).ToArray();
// Snapshot the persisted names before resolving, as FindExtras corrects the name on the
// items it hands back and may well hand back these very instances.
var currentExtraNames = new Dictionary<Guid, string>();
foreach (var extra in currentExtras)
{
currentExtraNames[extra.Id] = extra.Name;
}
var extras = LibraryManager.FindExtras(item, fileSystemChildren, options.DirectoryService).ToArray();
var newExtraIds = Array.ConvertAll(extras, x => x.Id);
var currentExtraIds = LibraryManager.GetItemList(new InternalItemsQuery()
{
OwnerIds = [item.Id]
}).Select(e => e.Id).ToArray();
var renamedExtraIds = extras
.Where(e => currentExtraNames.TryGetValue(e.Id, out var oldName) && !string.Equals(oldName, e.Name, StringComparison.Ordinal))
.Select(e => e.Id)
.ToHashSet();
var extrasChanged = !currentExtraIds.OrderBy(x => x).SequenceEqual(newExtraIds.OrderBy(x => x));
if (!extrasChanged && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh)
if (!extrasChanged && renamedExtraIds.Count == 0 && !options.ReplaceAllMetadata && options.MetadataRefreshMode != MetadataRefreshMode.FullRefresh)
{
// The owner's dates may only have become known after its extras were created, so keep
// them in sync even when there is nothing to refresh.
foreach (var extra in currentExtras)
{
if (extra.ExtraType is not null && InheritDatesFromOwner(item, extra))
{
await extra.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
}
}
return false;
}
var ownerId = item.Id;
var tasks = extras.Select(i =>
{
var ownerId = item.GetOwnerIdForExtra(i);
var subOptions = new MetadataRefreshOptions(options);
if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty())
if (!i.OwnerId.Equals(ownerId) || !i.ParentId.IsEmpty() || renamedExtraIds.Contains(i.Id))
{
subOptions.ForceSave = true;
}
i.OwnerId = ownerId;
i.ParentId = Guid.Empty;
return RefreshMetadataForOwnedItem(i, true, subOptions, cancellationToken);
});
@@ -2639,6 +2696,32 @@ namespace MediaBrowser.Controller.Entities
}
}
/// <summary>
/// Applies the owner's premiere date and production year to an owned item, returning whether anything changed.
/// </summary>
/// <param name="owner">The owner.</param>
/// <param name="ownedItem">The owned item.</param>
/// <returns><c>true</c> if the owned item was changed, else <c>false</c>.</returns>
internal static bool InheritDatesFromOwner(BaseItem owner, BaseItem ownedItem)
{
// Extras have no release date of their own, so the owner's is authoritative.
var changed = false;
if (owner.ProductionYear is not null && ownedItem.ProductionYear != owner.ProductionYear)
{
ownedItem.ProductionYear = owner.ProductionYear;
changed = true;
}
if (owner.PremiereDate is not null && ownedItem.PremiereDate != owner.PremiereDate)
{
ownedItem.PremiereDate = owner.PremiereDate;
changed = true;
}
return changed;
}
protected async Task RefreshMetadataForOwnedItem(BaseItem ownedItem, bool copyTitleMetadata, MetadataRefreshOptions options, CancellationToken cancellationToken)
{
var newOptions = new MetadataRefreshOptions(options)
@@ -2698,6 +2781,11 @@ namespace MediaBrowser.Controller.Entities
ownedItem.CustomRating = item.CustomRating;
newOptions.ForceSave = true;
}
if (InheritDatesFromOwner(item, ownedItem))
{
newOptions.ForceSave = true;
}
}
await ownedItem.RefreshMetadata(newOptions, cancellationToken).ConfigureAwait(false);
@@ -2863,6 +2951,25 @@ namespace MediaBrowser.Controller.Entities
return [Id];
}
/// <summary>
/// Gets the ids of this item and the versions of it whose extras it maintains.
/// </summary>
/// <returns>An array containing the version ids.</returns>
protected virtual Guid[] GetOwnedVersionIds()
{
return [Id];
}
/// <summary>
/// Gets the id of the version an extra belongs to.
/// </summary>
/// <param name="extra">The extra.</param>
/// <returns>The id of the owning version.</returns>
protected virtual Guid GetOwnerIdForExtra(BaseItem extra)
{
return Id;
}
/// <summary>
/// Get all extras associated with this item, sorted by <see cref="SortName"/>.
/// </summary>
+23 -15
View File
@@ -43,11 +43,7 @@ namespace MediaBrowser.Controller.Entities
public class Folder : BaseItem
{
private IEnumerable<BaseItem> _children;
public Folder()
{
LinkedChildren = Array.Empty<LinkedChild>();
}
private LinkedChild[] _linkedChildren = [];
public static IUserViewManager UserViewManager { get; set; }
@@ -63,7 +59,27 @@ namespace MediaBrowser.Controller.Entities
/// Gets or sets the linked children.
/// </summary>
[JsonIgnore]
public LinkedChild[] LinkedChildren { get; set; }
public LinkedChild[] LinkedChildren
{
get => _linkedChildren;
set
{
_linkedChildren = value;
// Assigning the collection means the caller knows the complete set of links.
LinkedChildrenLoaded = true;
}
}
/// <summary>
/// Gets a value indicating whether <see cref="LinkedChildren"/> holds the stored set of links.
/// </summary>
/// <remarks>
/// An unloaded instance carries an empty array that means "unknown", not "no children" —
/// persisting it would delete every link the item has.
/// </remarks>
[JsonIgnore]
public bool LinkedChildrenLoaded { get; private set; }
[JsonIgnore]
public DateTime? DateLastMediaAdded { get; set; }
@@ -1085,15 +1101,7 @@ namespace MediaBrowser.Controller.Entities
items = ApplyNameFilter(items, query);
}
var filteredItems = items as IReadOnlyList<BaseItem> ?? items.ToList();
var result = UserViewBuilder.SortAndPage(filteredItems, null, query, LibraryManager);
if (query.EnableTotalRecordCount)
{
result.TotalRecordCount = filteredItems.Count;
}
return result;
return UserViewBuilder.SortAndPage(items, null, query, LibraryManager);
}
private static IEnumerable<BaseItem> ApplyNameFilter(IEnumerable<BaseItem> items, InternalItemsQuery query)
@@ -496,6 +496,12 @@ namespace MediaBrowser.Controller.Entities
public IReadOnlyList<string> SubtitleLanguages { get; set; }
/// <summary>
/// Gets a value indicating whether some content in the library is hidden from <see cref="User"/>.
/// Filters that only exist to hide content can be skipped entirely when this is false.
/// </summary>
public bool UserHasContentRestrictions { get; private set; }
public void SetUser(User user)
{
var maxRating = user.MaxParentalRatingScore;
@@ -519,6 +525,7 @@ namespace MediaBrowser.Controller.Entities
.Select(tag => tag.RemoveDiacritics().ToLowerInvariant())
.ToArray();
UserHasContentRestrictions = user.HasContentRestrictions();
User = user;
}
@@ -19,8 +19,16 @@ namespace MediaBrowser.Controller.Entities
{
PersonTypes = personTypes;
ExcludePersonTypes = excludePersonTypes;
EnableTotalRecordCount = true;
}
/// <summary>
/// Gets or sets a value indicating whether to count the matching people. Under an
/// <see cref="AccessFilter"/> the count is the expensive half of the query: the page walk stops
/// at the limit, the count has to check every person.
/// </summary>
public bool EnableTotalRecordCount { get; set; }
public int? StartIndex { get; set; }
/// <summary>
@@ -51,5 +59,11 @@ namespace MediaBrowser.Controller.Entities
public User User { get; set; }
public bool? IsFavorite { get; set; }
/// <summary>
/// Gets or sets the item query whose access settings (library access, parental rating, tags)
/// people must satisfy through at least one of the items they are credited on.
/// </summary>
public InternalItemsQuery AccessFilter { get; set; }
}
}
@@ -90,7 +90,7 @@ namespace MediaBrowser.Controller.Entities.Movies
{
var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata);
if (!ProductionYear.HasValue)
if (ProductionYear is null)
{
var info = LibraryManager.ParseName(Name);
@@ -40,7 +40,7 @@ namespace MediaBrowser.Controller.Entities
{
var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata);
if (!ProductionYear.HasValue)
if (ProductionYear is null)
{
var info = LibraryManager.ParseName(Name);
@@ -35,57 +35,61 @@ namespace MediaBrowser.Controller.Entities
person.Type = PersonKind.Writer;
}
// If the type is GuestStar and there's already an Actor entry, then update it to avoid dupes
if (person.Type == PersonKind.GuestStar)
{
var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && p.Type == PersonKind.Actor);
// Check for dupes based on the combination of Name, Type and Role.
var existing = people.FirstOrDefault(p => IsSameCredit(p, person)
&& string.Equals(p.Role ?? string.Empty, person.Role ?? string.Empty, StringComparison.OrdinalIgnoreCase));
if (existing is not null)
{
existing.Type = PersonKind.GuestStar;
MergeExisting(existing, person);
return;
}
}
if (person.Type == PersonKind.Actor)
if (existing is null)
{
// If the actor already exists without a role and we have one, fill it in
var existing = people.FirstOrDefault(p => p.Name.Equals(person.Name, StringComparison.OrdinalIgnoreCase) && (p.Type == PersonKind.Actor || p.Type == PersonKind.GuestStar));
if (existing is null)
if (string.IsNullOrEmpty(person.Role))
{
// Wasn't there - add it
people.Add(person);
existing = people.FirstOrDefault(p => IsSameCredit(p, person));
}
else
{
// Was there, if no role and we have one - fill it in
if (string.IsNullOrEmpty(existing.Role) && !string.IsNullOrEmpty(person.Role))
// If the person already exists without a role and we have one, fill it in
existing = people.FirstOrDefault(p => IsSameCredit(p, person) && string.IsNullOrEmpty(p.Role));
if (existing is not null)
{
existing.Role = person.Role;
}
MergeExisting(existing, person);
}
}
else
if (existing is null)
{
var existing = people.FirstOrDefault(p =>
string.Equals(p.Name, person.Name, StringComparison.OrdinalIgnoreCase)
&& p.Type == person.Type);
// Check for dupes based on the combination of Name and Type
if (existing is null)
{
people.Add(person);
}
else
{
MergeExisting(existing, person);
}
people.Add(person);
return;
}
// If the type is GuestStar and there's already an Actor entry, then promote it to avoid dupes
if (person.Type == PersonKind.GuestStar)
{
existing.Type = PersonKind.GuestStar;
}
MergeExisting(existing, person);
}
private static bool IsSameCredit(PersonInfo existing, PersonInfo person)
{
if (!string.Equals(existing.Name, person.Name, StringComparison.OrdinalIgnoreCase))
{
return false;
}
// Actor and GuestStar describe the same credit, a guest star is just a promoted actor.
if (IsCastKind(existing.Type) && IsCastKind(person.Type))
{
return true;
}
return existing.Type == person.Type;
}
private static bool IsCastKind(PersonKind kind)
=> kind is PersonKind.Actor or PersonKind.GuestStar;
private static void MergeExisting(PersonInfo existing, PersonInfo person)
{
existing.SortOrder = person.SortOrder ?? existing.SortOrder;

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