Compare commits

...

117 Commits

Author SHA1 Message Date
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
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
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
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
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 4cedbe28e3 Apply review suggestion 2026-07-28 22:18:18 +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
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
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
114 changed files with 9087 additions and 1044 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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/autobuild@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4.37.1
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
+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
+3
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,8 @@
- [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)
# 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.1" />
<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('/');
}
+19 -6
View File
@@ -1088,7 +1088,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))
{
@@ -1519,11 +1519,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 +1540,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 +1598,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 +1657,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;
@@ -2315,9 +2320,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;
@@ -3195,11 +3204,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 +3285,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 +3303,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 +3355,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 +3374,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 +3382,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)
@@ -3898,5 +3983,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
@@ -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);
}
}
@@ -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"
}
@@ -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",
@@ -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,58 @@
"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 uppsent"
}
@@ -105,5 +105,6 @@
"TaskExtractMediaSegmentsDescription": "मीडियासेगमेंट सक्षम प्लगइन्स से मीडिया सेगमेंट निकालता है या प्राप्त करता है।",
"TaskMoveTrickplayImages": "ट्रिकप्ले छवि स्थान माइग्रेट करें",
"TaskMoveTrickplayImagesDescription": "लाइब्रेरी सेटिंग्स के अनुसार मौजूदा ट्रिकप्ले फ़ाइलों को स्थानांतरित करता है।",
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य"
"CleanupUserDataTask": "यूज़र डेटा सफाई कार्य",
"Original": "असली"
}
@@ -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",
@@ -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."
}
@@ -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}"
}
@@ -106,5 +106,7 @@
"TaskExtractMediaSegmentsDescription": "แยกหรือดึงส่วนของสื่อจากปลั๊กอินที่เปิดใช้งาน MediaSegment",
"TaskMoveTrickplayImages": "ย้ายตำแหน่งเก็บภาพตัวอย่าง Trickplay",
"CleanupUserDataTask": "ส่วนงานล้างข้อมูลผู้ใช้",
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน"
"CleanupUserDataTaskDescription": "ล้างข้อมูลผู้ใช้ทั้งหมด (สถานะการรับชม สถานะรายการโปรด ฯลฯ) จากสื่อที่ไม่ได้ใช้งานแล้วอย่างน้อย 90 วัน",
"LyricDownloadFailureFromForItem": "ไม่สามารถดาวน์โหลดเนื้อเพลงจาก {0} สำหรับ {1}",
"Original": "ต้นฉบับ"
}
@@ -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)
{
@@ -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)
{
@@ -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;
}
@@ -1607,8 +1607,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);
@@ -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;
}
}
@@ -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
@@ -168,21 +168,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 +200,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 +316,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 +327,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,7 +422,8 @@ 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);
}
@@ -503,62 +536,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,54 @@ 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));
}
var orderedAlbums = topAlbumsQuery
.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 +199,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>
@@ -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,
@@ -434,115 +438,77 @@ 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))
});
// Folders 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);
}
var folderResumableFilter = 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 +516,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(IsFolderFilter.And(folderResumableFilter)
.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,11 +543,8 @@ 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(folderResumableFilter.Not())
.Or(IsFolderFilter.Not().And(e => !resumableMovieIds.Contains(e.Id))));
}
}
@@ -1150,16 +1113,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 +1141,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 +1154,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 +1167,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 +1196,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;
}
}
@@ -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
@@ -428,106 +428,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 +615,7 @@ public class ItemPersistenceService : IItemPersistenceService
.ToHashSet()
: [];
int sortOrder = 0;
var sortOrder = 0;
foreach (var (childId, childType) in newLinkedChildren)
{
if (!existingChildIds.Contains(childId))
@@ -590,36 +628,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
@@ -159,12 +159,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,
@@ -79,7 +79,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 +92,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();
@@ -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;
}
}
+107 -18
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
{
@@ -926,19 +927,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 +969,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);
}
@@ -1530,33 +1543,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 +2678,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 +2763,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 +2933,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)
@@ -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);
+38 -14
View File
@@ -4,7 +4,6 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Text.Json.Serialization;
using System.Threading;
@@ -82,16 +81,23 @@ namespace MediaBrowser.Controller.Entities.TV
{
var userdatakeys = GetUserDataKeys();
if (userdatakeys.Count > 1)
// The first user data key is a stable cross-folder identity.
// When none exists, fall back to the (normalized) series name.
var groupingKey = userdatakeys.Count > 1
? userdatakeys[0]
: GetNameBasedGroupingKey();
if (!string.IsNullOrEmpty(groupingKey))
{
return AddLibrariesToPresentationUniqueKey(userdatakeys[0]);
return AppendPreferredLanguage(groupingKey);
}
}
return base.CreatePresentationUniqueKey();
}
private string AddLibrariesToPresentationUniqueKey(string key)
// The owning libraries are deliberately NOT part of the key.
private string AppendPreferredLanguage(string key)
{
var lang = GetPreferredMetadataLanguage();
if (!string.IsNullOrEmpty(lang))
@@ -99,16 +105,15 @@ namespace MediaBrowser.Controller.Entities.TV
key += "-" + lang;
}
var folders = LibraryManager.GetCollectionFolders(this)
.Select(i => i.Id.ToString("N", CultureInfo.InvariantCulture))
.ToArray();
return key;
}
if (folders.Length == 0)
{
return key;
}
return key + "-" + string.Join('-', folders);
private string GetNameBasedGroupingKey()
{
// Prefix with the type so a series can never collide with a same-named item of another kind.
return string.IsNullOrEmpty(Name)
? null
: "series-" + Name.ToLowerInvariant();
}
private static string GetUniqueSeriesKey(BaseItem series)
@@ -188,6 +193,25 @@ namespace MediaBrowser.Controller.Entities.TV
return list;
}
/// <inheritdoc />
protected override Guid[] GetExtraOwnerIds()
{
if (!LibraryManager.GetLibraryOptions(this).EnableAutomaticSeriesGrouping)
{
return base.GetExtraOwnerIds();
}
// Setting PresentationUniqueKey on the query disables presentation-key grouping, so this
// returns every folder-item of the merged series rather than the collapsed survivor.
var ids = LibraryManager.GetItemIds(new InternalItemsQuery
{
PresentationUniqueKey = GetPresentationUniqueKey(),
IncludeItemTypes = [BaseItemKind.Series]
});
return ids.Count == 0 ? base.GetExtraOwnerIds() : ids.ToArray();
}
public override IReadOnlyList<BaseItem> GetChildren(User user, bool includeLinkedChildren, InternalItemsQuery query)
{
return GetSeasons(user, new DtoOptions(true));
@@ -507,7 +531,7 @@ namespace MediaBrowser.Controller.Entities.TV
{
var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata);
if (!ProductionYear.HasValue)
if (ProductionYear is null)
{
var info = LibraryManager.ParseName(Name);
+1 -1
View File
@@ -49,7 +49,7 @@ namespace MediaBrowser.Controller.Entities
{
var hasChanges = base.BeforeMetadataRefresh(replaceAllMetadata);
if (!ProductionYear.HasValue)
if (ProductionYear is null)
{
var info = LibraryManager.ParseName(Name);
@@ -461,11 +461,12 @@ namespace MediaBrowser.Controller.Entities
var counts = libraryManager.GetPlayedAndTotalCountBatch(folderIds, user);
var isPlayedValue = query.IsPlayed.Value;
return itemList.Where(i =>
return itemList.Where(item =>
{
if (i.IsFolder && counts.TryGetValue(i.Id, out var c))
if (item is Folder)
{
return (c.Total > 0 && c.Played == c.Total) == isPlayedValue;
var itemCount = counts.GetValueOrDefault(item.Id);
return (itemCount.Played >= itemCount.Total) == isPlayedValue;
}
return true;
@@ -490,6 +491,13 @@ namespace MediaBrowser.Controller.Entities
}
var itemsArray = totalRecordLimit.HasValue ? items.Take(totalRecordLimit.Value).ToArray() : items.ToArray();
// Adjacency is defined by the order the query asked for, so it has to run after sorting but before paging.
if (!query.AdjacentTo.IsNullOrEmpty())
{
itemsArray = FilterForAdjacency(itemsArray, query.AdjacentTo.Value).ToArray();
}
var totalCount = itemsArray.Length;
if (query.Limit.HasValue && query.Limit.Value > 0)
@@ -730,7 +738,7 @@ namespace MediaBrowser.Controller.Entities
// Apply year filter
if (query.Years.Length > 0)
{
if (!(item.ProductionYear.HasValue && query.Years.Contains(item.ProductionYear.Value)))
if (item.ProductionYear is null || !query.Years.Contains(item.ProductionYear.Value))
{
return false;
}
@@ -886,26 +894,32 @@ namespace MediaBrowser.Controller.Entities
return _userViewManager.GetUserSubView(parent.Id, type, localizationKey, sortName);
}
public static IEnumerable<BaseItem> FilterForAdjacency(List<BaseItem> list, Guid adjacentTo)
/// <summary>
/// Trims an ordered list down to the requested item and its immediate neighbours.
/// </summary>
/// <param name="list">The items in the order the query returned them.</param>
/// <param name="adjacentTo">The id of the item to return the neighbours of.</param>
/// <returns>The previous item, the requested item and the next item, in order.</returns>
public static IEnumerable<BaseItem> FilterForAdjacency(IReadOnlyList<BaseItem> list, Guid adjacentTo)
{
var adjacentToItem = list.FirstOrDefault(i => i.Id.Equals(adjacentTo));
var index = list.IndexOf(adjacentToItem);
var previousId = Guid.Empty;
var nextId = Guid.Empty;
if (index > 0)
var index = -1;
for (var i = 0; i < list.Count; i++)
{
previousId = list[index - 1].Id;
if (list[i].Id.Equals(adjacentTo))
{
index = i;
break;
}
}
if (index < list.Count - 1)
// The item isn't part of this result set, so it has no neighbours in it either.
if (index < 0)
{
nextId = list[index + 1].Id;
return [];
}
return list.Where(i => i.Id.Equals(previousId) || i.Id.Equals(nextId) || i.Id.Equals(adjacentTo));
var start = Math.Max(index - 1, 0);
return list.Skip(start).Take(Math.Min(index + 2, list.Count) - start);
}
}
}
+74
View File
@@ -751,6 +751,80 @@ namespace MediaBrowser.Controller.Entities
.ToArray();
}
/// <inheritdoc />
protected override Guid[] GetOwnedVersionIds()
{
// Only the versions that live beside this one in the folder this scan covers. Linked
// versions are items of their own and maintain their extras themselves.
return [Id, .. LibraryManager.GetLocalAlternateVersionIds(this)];
}
/// <inheritdoc />
protected override Guid GetOwnerIdForExtra(BaseItem extra)
{
if (string.IsNullOrEmpty(extra.Path))
{
return Id;
}
var extraDirectory = System.IO.Path.GetDirectoryName(extra.Path.AsSpan());
var extraFileName = System.IO.Path.GetFileNameWithoutExtension(extra.Path.AsSpan());
var ownerId = Id;
var matchedLength = MatchedVersionNameLength(Path, extraDirectory, extraFileName);
foreach (var versionId in LibraryManager.GetLocalAlternateVersionIds(this))
{
var version = LibraryManager.GetItemById(versionId);
if (version is null)
{
continue;
}
// "Movie - [2160p]-trailer.mkv" belongs to "Movie - [2160p].mkv" rather than to the
// primary version, whose name it also starts with when the primary is plain "Movie.mkv"
var length = MatchedVersionNameLength(version.Path, extraDirectory, extraFileName);
if (length > matchedLength)
{
matchedLength = length;
ownerId = versionId;
}
}
return ownerId;
}
/// <summary>
/// Gets how much of an extra's file name is the name of the given version file, or 0 when the
/// extra is not named after it.
/// </summary>
/// <param name="versionPath">The path of the version.</param>
/// <param name="extraDirectory">The directory the extra lives in.</param>
/// <param name="extraFileName">The file name of the extra, without extension.</param>
/// <returns>The length of the match.</returns>
private static int MatchedVersionNameLength(string versionPath, ReadOnlySpan<char> extraDirectory, ReadOnlySpan<char> extraFileName)
{
if (string.IsNullOrEmpty(versionPath)
|| !System.IO.Path.GetDirectoryName(versionPath.AsSpan()).Equals(extraDirectory, StringComparison.OrdinalIgnoreCase))
{
return 0;
}
var versionFileName = System.IO.Path.GetFileNameWithoutExtension(versionPath.AsSpan());
if (versionFileName.IsEmpty || !extraFileName.StartsWith(versionFileName, StringComparison.OrdinalIgnoreCase))
{
return 0;
}
// The version name has to end where the extra's own name begins, so that a version
// named "Movie - 4K" does not claim the extras of "Movie - 4Kish"
var remainder = extraFileName[versionFileName.Length..];
return !remainder.IsEmpty && (remainder[0] == ' ' || Array.IndexOf(VersionDelimiters, remainder[0]) >= 0)
? versionFileName.Length
: 0;
}
protected override IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)> GetAllItemsForMediaSources()
{
var primary = PrimaryVersionId.HasValue
@@ -1,5 +1,6 @@
using System;
using System.Linq;
using System.Linq.Expressions;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
@@ -79,17 +80,26 @@ public interface IItemQueryHelpers
Guid ancestorId);
/// <summary>
/// Builds an <see cref="IQueryable{Guid}"/> of folder IDs whose descendants are all played
/// for the given user. Composable into outer queries to avoid an extra DB roundtrip.
/// Builds a query for the playable leaf items a user can access.
/// </summary>
/// <param name="context">The database context the resulting query is bound to.</param>
/// <param name="folderIds">A query yielding candidate folder IDs.</param>
/// <param name="user">The user for access filtering and played status.</param>
/// <returns>An <see cref="IQueryable{Guid}"/> of fully-played folder IDs.</returns>
IQueryable<Guid> GetFullyPlayedFolderIdsQuery(
/// <param name="user">The user to filter accessible items for.</param>
/// <param name="includeOwnedItems">Whether to include alternate versions and owned items.</param>
/// <returns>The access-filtered leaf item queryable.</returns>
IQueryable<BaseItemEntity> GetAccessFilteredLeafItemsQuery(
JellyfinDbContext context,
IQueryable<Guid> folderIds,
User user);
User user,
bool includeOwnedItems = false);
/// <summary>
/// Builds a filter matching items that have at least one of <paramref name="descendants"/> below them.
/// </summary>
/// <param name="context">The database context the resulting filter is bound to.</param>
/// <param name="descendants">A query yielding the descendants to look for.</param>
/// <returns>A filter expression matching items with a matching descendant.</returns>
Expression<Func<BaseItemEntity, bool>> BuildHasDescendantFilter(
JellyfinDbContext context,
IQueryable<BaseItemEntity> descendants);
/// <summary>
/// Deserializes a <see cref="BaseItemEntity"/> into a <see cref="BaseItem"/>.
@@ -272,7 +272,7 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
public void SetPlayingItemByIndex(int playlistIndex)
{
var playlist = GetPlaylistInternal();
if (playlistIndex < 0 || playlistIndex > playlist.Count)
if (playlistIndex < 0 || playlistIndex >= playlist.Count)
{
PlayingItemIndex = NoPlayingItemIndex;
}
@@ -293,6 +293,15 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
{
var playingItem = GetPlayingItem();
// Removed items that precede the playing item shift its index as well.
var removedBeforePlayingItem = 0;
if (playingItem is not null)
{
removedBeforePlayingItem = GetPlaylistInternal()
.Take(PlayingItemIndex)
.Count(item => playlistItemIds.Contains(item.PlaylistItemId));
}
_sortedPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
_shuffledPlaylist.RemoveAll(item => playlistItemIds.Contains(item.PlaylistItemId));
@@ -303,12 +312,12 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
if (playlistItemIds.Contains(playingItem.PlaylistItemId))
{
// Playing item has been removed, picking previous item.
PlayingItemIndex--;
PlayingItemIndex -= removedBeforePlayingItem + 1;
if (PlayingItemIndex < 0)
{
// Was first element, picking next if available.
// Default to no playing item otherwise.
PlayingItemIndex = _sortedPlaylist.Count > 0 ? 0 : NoPlayingItemIndex;
PlayingItemIndex = GetPlaylistInternal().Count > 0 ? 0 : NoPlayingItemIndex;
}
return true;
@@ -444,6 +453,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
public bool Next()
{
if (GetPlaylistInternal().Count == 0)
{
return false;
}
if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
{
LastChange = DateTime.UtcNow;
@@ -474,6 +488,11 @@ namespace MediaBrowser.Controller.SyncPlay.Queue
/// <returns><c>true</c> if the playing item changed; <c>false</c> otherwise.</returns>
public bool Previous()
{
if (GetPlaylistInternal().Count == 0)
{
return false;
}
if (RepeatMode.Equals(GroupRepeatMode.RepeatOne))
{
LastChange = DateTime.UtcNow;
@@ -277,7 +277,7 @@ namespace MediaBrowser.LocalMetadata.Savers
await writer.WriteElementStringAsync(null, "Rating", null, item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
}
if (item.ProductionYear.HasValue && item is not Person)
if (item.ProductionYear is not null && item is not Person)
{
await writer.WriteElementStringAsync(null, "ProductionYear", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
}
@@ -8,6 +8,7 @@ using System.Linq;
using System.Runtime.Versioning;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
@@ -662,8 +663,15 @@ namespace MediaBrowser.MediaEncoding.Encoder
writer.Write(testKey);
}
using var reader = readStdErr ? process.StandardError : process.StandardOutput;
return reader.ReadToEnd();
// Drain both streams concurrently to prevent pipe hanging, see #17429
using var standardOutput = process.StandardOutput;
using var standardError = process.StandardError;
var standardOutputTask = standardOutput.ReadToEndAsync();
var standardErrorTask = standardError.ReadToEndAsync();
process.WaitForExit();
Task.WaitAll(standardOutputTask, standardErrorTask);
return (readStdErr ? standardErrorTask : standardOutputTask).GetAwaiter().GetResult();
}
}
@@ -189,7 +189,7 @@ namespace MediaBrowser.MediaEncoding.Probing
}
// Guess ProductionYear from PremiereDate if missing
if (!info.ProductionYear.HasValue && info.PremiereDate.HasValue)
if (info.ProductionYear is null && info.PremiereDate is not null)
{
info.ProductionYear = info.PremiereDate.Value.Year;
}
@@ -757,11 +757,17 @@ namespace MediaBrowser.MediaEncoding.Probing
if (string.IsNullOrEmpty(stream.Title))
{
// mp4 missing track title workaround: fall back to handler_name if populated and not the default "SoundHandler"
string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SoundHandler", StringComparison.OrdinalIgnoreCase))
// FFprobe exposes MP4 track names via the name tag rather than title
stream.Title = GetDictionaryValue(streamInfo.Tags, "name");
if (string.IsNullOrEmpty(stream.Title))
{
stream.Title = handlerName;
// fall back to handler_name if populated and not the default "SoundHandler"
string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SoundHandler", StringComparison.OrdinalIgnoreCase))
{
stream.Title = handlerName;
}
}
}
}
@@ -781,11 +787,17 @@ namespace MediaBrowser.MediaEncoding.Probing
if (string.IsNullOrEmpty(stream.Title))
{
// mp4 missing track title workaround: fall back to handler_name if populated and not the default "SubtitleHandler"
string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SubtitleHandler", StringComparison.OrdinalIgnoreCase))
// FFprobe exposes MP4 track names via the name tag rather than title
stream.Title = GetDictionaryValue(streamInfo.Tags, "name");
if (string.IsNullOrEmpty(stream.Title))
{
stream.Title = handlerName;
// fall back to handler_name if populated and not the default "SubtitleHandler"
string handlerName = GetDictionaryValue(streamInfo.Tags, "handler_name");
if (!string.IsNullOrEmpty(handlerName) && !string.Equals(handlerName, "SubtitleHandler", StringComparison.OrdinalIgnoreCase))
{
stream.Title = handlerName;
}
}
}
}
+5 -1
View File
@@ -1582,7 +1582,11 @@ namespace MediaBrowser.Model.Dlna
continue;
}
if (!subtitleStream.IsExternal && playMethod == PlayMethod.Transcode && !transcoderSupport.CanExtractSubtitles(subtitleStream.Codec))
if (!subtitleStream.IsExternal
&& playMethod == PlayMethod.Transcode
&& !transcoderSupport.CanExtractSubtitles(subtitleStream.Codec)
&& !subtitleStream.IsPgsSubtitleStream
&& !subtitleStream.IsVobSubSubtitleStream)
{
continue;
}
@@ -42,7 +42,7 @@ public class ComicBookInfoProvider : IComicProvider
if (path is null)
{
_logger.LogError("could not load comic: {Path}", info.Path);
_logger.LogDebug("could not load comic: {Path}", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
@@ -38,7 +38,7 @@ public class ExternalComicInfoProvider : IComicProvider
if (comicInfoXml is null)
{
_logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file.", info.Path);
_logger.LogDebug("No external ComicInfo metadata found for {Path}.", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
@@ -67,23 +67,22 @@ public class ExternalComicInfoProvider : IComicProvider
private async Task<XDocument?> LoadXml(ItemInfo info, CancellationToken cancellationToken)
{
var path = GetXmlFilePath(info.Path).FullName;
if (path is null)
var file = GetXmlFilePath(info.Path);
if (!file.Exists)
{
return null;
}
try
{
using var reader = XmlReader.Create(path, new XmlReaderSettings { Async = true });
using var reader = XmlReader.Create(file.FullName, new XmlReaderSettings { Async = true });
var comicInfoXml = XDocument.LoadAsync(reader, LoadOptions.None, cancellationToken);
return await comicInfoXml.ConfigureAwait(false);
}
catch (Exception e)
{
_logger.LogInformation(e, "Could not load external XML from {Path}. This could mean there is no separate ComicInfo metadata file for this comic or the metadata is bundled within the comic.", path);
_logger.LogWarning(e, "Could not load external ComicInfo XML from {Path}.", file.FullName);
return null;
}
}
@@ -36,7 +36,7 @@ public class InternalComicInfoProvider : IComicProvider
if (comicInfoXml is null)
{
_logger.LogInformation("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path);
_logger.LogDebug("Could not load ComicInfo metadata for {Path} from XML file. No internal XML in comic archive.", info.Path);
return new MetadataResult<Book> { HasMetadata = false };
}
@@ -1038,6 +1038,11 @@ namespace MediaBrowser.Providers.Manager
target.OriginalTitle = source.OriginalTitle;
}
if (replaceData || string.IsNullOrEmpty(target.HomePageUrl))
{
target.HomePageUrl = source.HomePageUrl;
}
if (replaceData || string.IsNullOrEmpty(target.OriginalLanguage))
{
target.OriginalLanguage = source.OriginalLanguage;
@@ -1114,7 +1119,7 @@ namespace MediaBrowser.Providers.Manager
target.PremiereDate = source.PremiereDate;
}
if (replaceData || !target.ProductionYear.HasValue)
if (replaceData || target.ProductionYear is null)
{
target.ProductionYear = source.ProductionYear;
}
@@ -436,6 +436,14 @@ namespace MediaBrowser.Providers.Manager
return false;
}
// Extras have no identity of their own in an online database, so remote artwork for them
// is always some other item's. Local and dynamic providers still apply, so an extra can
// keep an embedded thumbnail or an extracted frame.
if (item.ExtraType.HasValue && provider is IRemoteImageProvider)
{
return false;
}
return _baseItemManager.IsImageFetcherEnabled(item, libraryTypeOptions, provider.Name);
}
@@ -584,6 +592,14 @@ namespace MediaBrowser.Providers.Manager
return true;
}
// An extra is a local file belonging to another item and has no identity of its own in an
// online database. Looking it up matches whatever the surrounding folder happens to be
// called and overwrites the extra's name with a different item's title.
if (item.ExtraType.HasValue)
{
return false;
}
// Artists without a folder structure that are derived from metadata have no real path in the library,
// so GetLibraryOptions returns null. Allow all providers through rather than blocking them.
if (item is MusicArtist && libraryTypeOptions is null)
@@ -386,7 +386,7 @@ namespace MediaBrowser.Providers.MediaInfo
}
}
private void FetchEmbeddedInfo(Video video, Model.MediaInfo.MediaInfo data, MetadataRefreshOptions refreshOptions, LibraryOptions libraryOptions)
internal void FetchEmbeddedInfo(Video video, Model.MediaInfo.MediaInfo data, MetadataRefreshOptions refreshOptions, LibraryOptions libraryOptions)
{
var replaceData = refreshOptions.ReplaceAllMetadata;
@@ -432,17 +432,19 @@ namespace MediaBrowser.Providers.MediaInfo
}
}
if (data.ProductionYear.HasValue)
// Extras have no release date of their own, they inherit it from the item they belong to.
var useContainerDates = video.ExtraType is null;
if (useContainerDates && data.ProductionYear is not null)
{
if (!video.ProductionYear.HasValue || replaceData)
if (video.ProductionYear is null || replaceData)
{
video.ProductionYear = data.ProductionYear;
}
}
if (data.PremiereDate.HasValue)
if (useContainerDates && data.PremiereDate is not null)
{
if (!video.PremiereDate.HasValue || replaceData)
if (video.PremiereDate is null || replaceData)
{
video.PremiereDate = data.PremiereDate;
}
@@ -482,7 +484,7 @@ namespace MediaBrowser.Providers.MediaInfo
}
// If we don't have a ProductionYear try and get it from PremiereDate
if (video.PremiereDate.HasValue && !video.ProductionYear.HasValue)
if (useContainerDates && video.PremiereDate is not null && video.ProductionYear is null)
{
video.ProductionYear = video.PremiereDate.Value.ToLocalTime().Year;
}
@@ -3,32 +3,24 @@
#pragma warning disable CS1591
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Extensions.Json;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Audio;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Providers;
namespace MediaBrowser.Providers.Plugins.AudioDb
{
public class AudioDbArtistImageProvider : IRemoteImageProvider, IHasOrder
{
private readonly IServerConfigurationManager _config;
private readonly IHttpClientFactory _httpClientFactory;
private readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options;
public AudioDbArtistImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory)
public AudioDbArtistImageProvider(IHttpClientFactory httpClientFactory)
{
_config = config;
_httpClientFactory = httpClientFactory;
}
@@ -54,22 +46,14 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
/// <inheritdoc />
public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken)
{
if (item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var id))
item.TryGetProviderId(MetadataProvider.MusicBrainzArtist, out var musicBrainzId);
item.TryGetProviderId(MetadataProvider.AudioDbArtist, out var audioDbId);
var artist = await AudioDbArtistProvider.Current.GetArtist(musicBrainzId, audioDbId, cancellationToken).ConfigureAwait(false);
if (artist is not null)
{
await AudioDbArtistProvider.Current.EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false);
var path = AudioDbArtistProvider.GetArtistInfoPath(_config.ApplicationPaths, id);
FileStream jsonStream = AsyncFile.OpenRead(path);
await using (jsonStream.ConfigureAwait(false))
{
var obj = await JsonSerializer.DeserializeAsync<AudioDbArtistProvider.RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
if (obj is not null && obj.artists is not null && obj.artists.Count > 0)
{
return GetImages(obj.artists[0]);
}
}
return GetImages(artist);
}
return [];
@@ -4,9 +4,11 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
@@ -52,45 +54,176 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
public int Order => 1;
/// <inheritdoc />
public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken)
=> Task.FromResult(Enumerable.Empty<RemoteSearchResult>());
/// <inheritdoc />
public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken)
public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken)
{
var result = new MetadataResult<MusicArtist>();
var id = info.GetMusicBrainzArtistId();
if (!string.IsNullOrWhiteSpace(id))
// Prefer a known TheAudioDB artist id.
var audioDbId = searchInfo.GetProviderId(MetadataProvider.AudioDbArtist);
if (!string.IsNullOrWhiteSpace(audioDbId))
{
await EnsureArtistInfo(id, cancellationToken).ConfigureAwait(false);
var artists = await FetchArtists(BaseUrl + "/artist.php?i=" + audioDbId, cancellationToken).ConfigureAwait(false);
return artists.Select(ToRemoteSearchResult);
}
var path = GetArtistInfoPath(_config.ApplicationPaths, id);
// Fall back to the MusicBrainz artist id, reusing the on-disk cache also used by GetMetadata.
var musicBrainzId = searchInfo.GetMusicBrainzArtistId();
if (!string.IsNullOrWhiteSpace(musicBrainzId))
{
await EnsureArtistInfo(musicBrainzId, cancellationToken).ConfigureAwait(false);
var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
FileStream jsonStream = AsyncFile.OpenRead(path);
await using (jsonStream.ConfigureAwait(false))
{
var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
if (obj is not null && obj.artists is not null && obj.artists.Count > 0)
if (obj is not null && obj.artists is not null)
{
result.Item = new MusicArtist();
result.HasMetadata = true;
ProcessResult(result.Item, obj.artists[0], info.MetadataLanguage);
return obj.artists.Select(ToRemoteSearchResult);
}
}
return [];
}
// Finally, search by name.
if (!string.IsNullOrWhiteSpace(searchInfo.Name))
{
var artists = await FetchArtists(BaseUrl + "/search.php?s=" + Uri.EscapeDataString(searchInfo.Name), cancellationToken).ConfigureAwait(false);
return artists.Select(ToRemoteSearchResult);
}
return [];
}
private async Task<List<Artist>> FetchArtists(string url, CancellationToken cancellationToken)
{
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var obj = await response.Content.ReadFromJsonAsync<RootObject>(_jsonOptions, cancellationToken).ConfigureAwait(false);
return obj?.artists ?? [];
}
private RemoteSearchResult ToRemoteSearchResult(Artist artist)
{
var result = new RemoteSearchResult
{
Name = artist.strArtist,
ImageUrl = artist.strArtistThumb,
SearchProviderName = Name,
Overview = (artist.strBiographyEN ?? string.Empty).StripHtml()
};
if (!string.IsNullOrEmpty(artist.idArtist))
{
result.SetProviderId(MetadataProvider.AudioDbArtist, artist.idArtist);
}
if (!string.IsNullOrEmpty(artist.strMusicBrainzID))
{
result.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.strMusicBrainzID);
}
if (int.TryParse(artist.intFormedYear, NumberStyles.Integer, CultureInfo.InvariantCulture, out var formedYear))
{
result.ProductionYear = formedYear;
}
return result;
}
/// <inheritdoc />
public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken)
{
var result = new MetadataResult<MusicArtist>();
var artist = await GetArtist(
info.GetMusicBrainzArtistId(),
info.GetProviderId(MetadataProvider.AudioDbArtist),
cancellationToken).ConfigureAwait(false);
if (artist is not null)
{
result.Item = new MusicArtist();
result.HasMetadata = true;
ProcessResult(result.Item, artist, info.MetadataLanguage);
}
return result;
}
/// <summary>
/// Resolves the cached AudioDB artist, preferring the MusicBrainz id and falling back to the AudioDB id.
/// </summary>
/// <param name="musicBrainzId">The MusicBrainz artist id, if known.</param>
/// <param name="audioDbId">The TheAudioDB artist id, if known.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The matching artist, or <c>null</c> if none could be resolved.</returns>
internal async Task<Artist> GetArtist(string musicBrainzId, string audioDbId, CancellationToken cancellationToken)
{
string path;
if (!string.IsNullOrWhiteSpace(musicBrainzId))
{
await EnsureArtistInfo(musicBrainzId, cancellationToken).ConfigureAwait(false);
path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
}
else if (!string.IsNullOrWhiteSpace(audioDbId))
{
await EnsureArtistInfoByAudioDbId(audioDbId, cancellationToken).ConfigureAwait(false);
path = GetArtistInfoPath(_config.ApplicationPaths, audioDbId);
}
else
{
return null;
}
FileStream jsonStream = AsyncFile.OpenRead(path);
await using (jsonStream.ConfigureAwait(false))
{
var obj = await JsonSerializer.DeserializeAsync<RootObject>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false);
if (obj is not null && obj.artists is not null && obj.artists.Count > 0)
{
return obj.artists[0];
}
}
return null;
}
private void ProcessResult(MusicArtist item, Artist result, string preferredLanguage)
{
// item.HomePageUrl = result.strWebsite;
if (!string.IsNullOrEmpty(result.strGenre))
if (!string.IsNullOrWhiteSpace(result.strWebsite))
{
item.Genres = new[] { result.strGenre };
item.HomePageUrl = result.strWebsite;
}
var genres = new List<string>();
if (!string.IsNullOrWhiteSpace(result.strGenre))
{
genres.Add(result.strGenre);
}
if (!string.IsNullOrWhiteSpace(result.strSubGenre))
{
genres.Add(result.strSubGenre);
}
if (genres.Count > 0)
{
item.Genres = genres.ToArray();
}
if (int.TryParse(result.intFormedYear, NumberStyles.Integer, CultureInfo.InvariantCulture, out var formedYear))
{
item.ProductionYear = formedYear;
}
if (!string.IsNullOrWhiteSpace(result.strCountry))
{
item.ProductionLocations = new[] { result.strCountry };
}
item.SetProviderId(MetadataProvider.AudioDbArtist, result.idArtist);
@@ -150,13 +283,32 @@ namespace MediaBrowser.Providers.Plugins.AudioDb
internal async Task DownloadArtistInfo(string musicBrainzId, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
var url = BaseUrl + "/artist-mb.php?i=" + musicBrainzId;
await DownloadArtistInfo(url, GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId), cancellationToken).ConfigureAwait(false);
}
internal async Task EnsureArtistInfoByAudioDbId(string audioDbId, CancellationToken cancellationToken)
{
var xmlPath = GetArtistInfoPath(_config.ApplicationPaths, audioDbId);
var fileInfo = _fileSystem.GetFileSystemInfo(xmlPath);
if (fileInfo.Exists
&& (DateTime.UtcNow - _fileSystem.GetLastWriteTimeUtc(fileInfo)).TotalDays <= 2)
{
return;
}
var url = BaseUrl + "/artist.php?i=" + audioDbId;
await DownloadArtistInfo(url, xmlPath, cancellationToken).ConfigureAwait(false);
}
private async Task DownloadArtistInfo(string url, string path, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId);
Directory.CreateDirectory(Path.GetDirectoryName(path));
var fileStreamOptions = AsyncFile.WriteOptions;
@@ -155,7 +155,6 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
/// <inheritdoc />
public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken)
{
// TODO: This sets essentially nothing. As-is, it's mostly useless. Make it actually pull metadata and use it.
var query = MusicBrainz.Plugin.Instance!.MusicBrainzQuery;
var releaseId = info.GetReleaseId();
var releaseGroupId = info.GetReleaseGroupId();
@@ -169,13 +168,8 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId))
{
// TODO: Actually try to match the release. Simply taking the first result is stupid.
var releaseGroup = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false);
var release = releaseGroup.Releases?.Count > 0 ? releaseGroup.Releases[0] : null;
if (release is not null)
{
releaseId = release.Id.ToString();
result.HasMetadata = true;
}
var releaseGroupLookup = await query.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false);
releaseId = releaseGroupLookup.Releases?.Count > 0 ? releaseGroupLookup.Releases[0].Id.ToString() : null;
}
// If there is no release ID, lookup a release with the info we have
@@ -205,43 +199,106 @@ public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, Albu
{
releaseGroupId = releaseResult.ReleaseGroup.Id.ToString();
}
result.HasMetadata = true;
result.Item.ProductionYear = releaseResult.Date?.Year;
result.Item.Overview = releaseResult.Annotation;
}
}
// If we have a release ID but not a release group ID, lookup the release group
if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId))
if (string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId))
{
var release = await query.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false);
releaseGroupId = release.ReleaseGroup?.Id.ToString();
result.HasMetadata = true;
return result;
}
// If we have a release ID and a release group ID
if (!string.IsNullOrWhiteSpace(releaseId) || !string.IsNullOrWhiteSpace(releaseGroupId))
// Fetch the full release (and its release group) so we can populate everything MusicBrainz returns.
IRelease? release = null;
if (!string.IsNullOrWhiteSpace(releaseId))
{
result.HasMetadata = true;
}
release = await query.LookupReleaseAsync(
new Guid(releaseId),
Include.Artists | Include.ReleaseGroups | Include.Labels | Include.Genres | Include.Tags,
cancellationToken).ConfigureAwait(false);
if (result.HasMetadata)
{
if (!string.IsNullOrEmpty(releaseId))
if (string.IsNullOrWhiteSpace(releaseGroupId) && release?.ReleaseGroup?.Id is not null)
{
result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId);
}
if (!string.IsNullOrEmpty(releaseGroupId))
{
result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId);
releaseGroupId = release.ReleaseGroup.Id.ToString();
}
}
IReleaseGroup? releaseGroup = null;
if (!string.IsNullOrWhiteSpace(releaseGroupId))
{
releaseGroup = await query.LookupReleaseGroupAsync(
new Guid(releaseGroupId),
Include.Artists | Include.Genres | Include.Tags,
null,
cancellationToken).ConfigureAwait(false);
}
result.HasMetadata = true;
if (!string.IsNullOrEmpty(releaseId))
{
result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId);
}
if (!string.IsNullOrEmpty(releaseGroupId))
{
result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId);
}
Populate(result.Item, release, releaseGroup);
return result;
}
private static void Populate(MusicAlbum item, IRelease? release, IReleaseGroup? releaseGroup)
{
// Prefer the release group (album-level) data, falling back to the specific release.
// The release group's first release date is the original album date.
var date = releaseGroup?.FirstReleaseDate ?? release?.Date;
if (date is not null)
{
item.PremiereDate = date.NearestDate;
item.ProductionYear = date.Year;
}
var artistCredit = release?.ArtistCredit ?? releaseGroup?.ArtistCredit;
if (artistCredit is not null && artistCredit.Count > 0)
{
item.AlbumArtists = artistCredit
.Select(credit => credit.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
}
var genres = releaseGroup?.Genres ?? release?.Genres;
if (genres is not null && genres.Count > 0)
{
item.Genres = genres
.OrderByDescending(genre => genre.VoteCount)
.Select(genre => genre.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
}
var tags = releaseGroup?.Tags ?? release?.Tags;
if (tags is not null && tags.Count > 0)
{
item.Tags = tags
.OrderByDescending(tag => tag.VoteCount)
.Select(tag => tag.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
}
if (release?.LabelInfo is not null && release.LabelInfo.Count > 0)
{
item.Studios = release.LabelInfo
.Where(labelInfo => !string.IsNullOrWhiteSpace(labelInfo.Label?.Name))
.Select(labelInfo => labelInfo.Label!.Name!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
}
}
/// <inheritdoc />
public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken)
{
@@ -40,6 +40,11 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
return GetResultFromResponse(artistResult).SingleItemAsEnumerable();
}
if (string.IsNullOrWhiteSpace(searchInfo.Name))
{
return [];
}
var artistSearchResults = await query.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken)
.ConfigureAwait(false);
if (artistSearchResults.Results.Count > 0)
@@ -58,7 +63,7 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
}
}
return Enumerable.Empty<RemoteSearchResult>();
return [];
}
private IEnumerable<RemoteSearchResult> GetResultsFromResponse(IEnumerable<ISearchResult<IArtist>>? releaseSearchResults)
@@ -96,28 +101,67 @@ public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, Ar
var musicBrainzId = info.GetMusicBrainzArtistId();
// If we don't have an id yet, resolve one by name so we can look the artist up.
if (string.IsNullOrWhiteSpace(musicBrainzId))
{
var searchResults = await GetSearchResults(info, cancellationToken).ConfigureAwait(false);
var singleResult = searchResults.FirstOrDefault();
if (singleResult is not null)
{
musicBrainzId = singleResult.GetProviderId(MetadataProvider.MusicBrainzArtist);
result.Item.Overview = singleResult.Overview;
if (Plugin.Instance!.Configuration.ReplaceArtistName)
{
result.Item.Name = singleResult.Name;
}
}
musicBrainzId = searchResults.FirstOrDefault()?.GetProviderId(MetadataProvider.MusicBrainzArtist);
}
if (!string.IsNullOrWhiteSpace(musicBrainzId))
if (string.IsNullOrWhiteSpace(musicBrainzId))
{
result.HasMetadata = true;
result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzId);
return result;
}
var query = Plugin.Instance!.MusicBrainzQuery;
var artist = await query.LookupArtistAsync(new Guid(musicBrainzId), Include.Genres | Include.Tags, null, null, cancellationToken).ConfigureAwait(false);
if (artist is null)
{
return result;
}
result.HasMetadata = true;
result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Id.ToString());
if (Plugin.Instance!.Configuration.ReplaceArtistName && !string.IsNullOrWhiteSpace(artist.Name))
{
result.Item.Name = artist.Name;
}
if (artist.LifeSpan?.Begin is not null)
{
result.Item.PremiereDate = artist.LifeSpan.Begin.NearestDate;
result.Item.ProductionYear = artist.LifeSpan.Begin.Year;
}
if (artist.LifeSpan?.End is not null)
{
result.Item.EndDate = artist.LifeSpan.End.NearestDate;
}
var location = string.IsNullOrWhiteSpace(artist.Area?.Name) ? artist.Country : artist.Area!.Name;
if (!string.IsNullOrWhiteSpace(location))
{
result.Item.ProductionLocations = [location];
}
if (artist.Genres is not null && artist.Genres.Count > 0)
{
result.Item.Genres = artist.Genres
.OrderByDescending(genre => genre.VoteCount)
.Select(genre => genre.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
}
if (artist.Tags is not null && artist.Tags.Count > 0)
{
result.Item.Tags = artist.Tags
.OrderByDescending(tag => tag.VoteCount)
.Select(tag => tag.Name)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToArray();
}
return result;
@@ -1,3 +1,5 @@
#pragma warning disable CA1819 // Properties should not return arrays
using MediaBrowser.Model.Plugins;
namespace MediaBrowser.Providers.Plugins.Tmdb
@@ -33,6 +35,51 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
/// </summary>
public bool ImportSeasonName { get; set; }
/// <summary>
/// Gets or sets a value indicating whether unaired (upcoming) episodes should be created as
/// virtual items from the episode list provided by TMDb. These populate the "Upcoming" view.
/// Enabling this will increase scan times.
/// </summary>
public bool ImportUnairedEpisodes { get; set; }
/// <summary>
/// Gets or sets a value indicating whether already aired episodes that are not present in the
/// library should be created as virtual items from the episode list provided by TMDb. These
/// surface as missing episodes. Enabling this will increase scan times.
/// </summary>
public bool ImportMissingEpisodes { get; set; }
/// <summary>
/// Gets or sets a value indicating whether specials (season 0) should be included when creating
/// virtual unaired or missing episodes. When disabled, specials are never added and any existing
/// virtual specials created by this provider are removed.
/// </summary>
public bool ImportSpecials { get; set; }
/// <summary>
/// Gets or sets the ids (the "N" formatted GUIDs from <c>VirtualFolderInfo.ItemId</c>) of the
/// libraries for which the unaired/missing episode provider is enabled. Whether episodes are
/// imported at all, and how, is still controlled by the global toggles above; those toggles only
/// apply to the libraries listed here. Libraries not listed, including newly added ones, are
/// never processed, so an empty list disables the feature entirely.
/// </summary>
public string[] EnabledMissingEpisodeLibraries { get; set; } = [];
/// <summary>
/// Gets or sets how often, in days, the scheduled task re-checks TMDb for newly announced
/// unaired or missing episodes. This is what keeps the "Upcoming" view current for series
/// whose local files have not changed.
/// </summary>
public int MissingEpisodeRefreshIntervalDays { get; set; } = 7;
/// <summary>
/// Gets or sets the number of days a virtual episode is retained after it airs before it is
/// pruned (when missing episode import is disabled). This grace period leaves recently aired
/// episodes in place to allow for the delay between an episode airing and its file being added
/// to the library.
/// </summary>
public int UpcomingEpisodeGracePeriodDays { get; set; } = 7;
/// <summary>
/// Gets or sets a value indicating the maximum number of cast members to fetch for an item.
/// </summary>
@@ -25,6 +25,40 @@
<input is="emby-checkbox" type="checkbox" id="importSeasonName" />
<span>Import season name from metadata fetched for series.</span>
</label>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="importUnairedEpisodes" />
<span>Create unaired (upcoming) episodes from metadata fetched for series.</span>
</label>
<div class="fieldDescription checkboxFieldDescription">Adds virtual entries for episodes listed on TMDb that have not aired yet. This populates the "Upcoming" view. Specials are never added to the "Upcoming" view.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="importMissingEpisodes" />
<span>Create missing episodes from metadata fetched for series.</span>
</label>
<div class="fieldDescription checkboxFieldDescription">Adds virtual entries for episodes listed on TMDb that have already aired but are not present in your library. Missing episodes are only shown when enabled in the user display preferences. Both options increase scan times.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input is="emby-checkbox" type="checkbox" id="importSpecials" />
<span>Include specials when creating unaired and missing episodes.</span>
</label>
<div class="fieldDescription checkboxFieldDescription">When disabled, specials (season 0) are never added as virtual entries and any existing virtual specials are removed.</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input is="emby-input" type="number" id="missingEpisodeRefreshIntervalDays" pattern="[0-9]*" required min="1" max="365" label="Episode refresh interval (days)" />
<div class="fieldDescription">How often the scheduled task re-checks TMDb for newly announced unaired or missing episodes. Keeps the "Upcoming" view current for series whose local files have not changed.</div>
</div>
<div class="inputContainer inputContainer-withDescription">
<input is="emby-input" type="number" id="upcomingEpisodeGracePeriodDays" pattern="[0-9]*" required min="0" max="365" label="Recently aired grace period (days)" />
<div class="fieldDescription">When missing episodes are disabled, how many days a recently aired episode is kept in place before its placeholder is removed. This allows for the delay between an episode airing and its file being added to the library.</div>
</div>
<div class="verticalSection">
<h2>Libraries</h2>
<div class="fieldDescription" style="margin-bottom:1em;">Choose which TV libraries the unaired/missing episode options above apply to. The settings above are global; this only controls which libraries they run for. Libraries are opted in individually, so newly added libraries are not processed until they are enabled here.</div>
<div id="missingEpisodeLibraries"></div>
</div>
<div class="verticalSection">
<h2>Cast & Crew Settings</h2>
<div class="inputContainer">
@@ -85,6 +119,29 @@
Dashboard.showLoadingMsg();
var clientConfig, pluginConfig;
var populateMissingEpisodeLibraries = function (enabledLibraries) {
var container = document.querySelector('#missingEpisodeLibraries');
ApiClient.getVirtualFolders().then(function (folders) {
// Series only live in TV libraries (and mixed-content libraries, which report
// no collection type), so only those are worth listing here.
var tvLibraries = folders.filter(function (folder) {
return !folder.CollectionType || folder.CollectionType === 'tvshows';
});
if (tvLibraries.length === 0) {
container.innerHTML = '<div class="fieldDescription">No TV libraries found.</div>';
return;
}
container.innerHTML = tvLibraries.map(function (folder) {
var checked = enabledLibraries.indexOf(folder.ItemId) === -1 ? '' : ' checked';
return '<label class="checkboxContainer">'
+ '<input is="emby-checkbox" type="checkbox" class="missingEpisodeLibrary" data-library-id="' + folder.ItemId + '"' + checked + ' />'
+ '<span>' + folder.Name + '</span>'
+ '</label>';
}).join('');
});
}
var configureImageScaling = function() {
if (clientConfig === undefined || pluginConfig === undefined) {
return;
@@ -151,9 +208,16 @@
document.querySelector('#excludeTagsSeries').checked = config.ExcludeTagsSeries;
document.querySelector('#excludeTagsMovies').checked = config.ExcludeTagsMovies;
document.querySelector('#importSeasonName').checked = config.ImportSeasonName;
document.querySelector('#importUnairedEpisodes').checked = config.ImportUnairedEpisodes;
document.querySelector('#importMissingEpisodes').checked = config.ImportMissingEpisodes;
document.querySelector('#importSpecials').checked = config.ImportSpecials;
document.querySelector('#missingEpisodeRefreshIntervalDays').value = config.MissingEpisodeRefreshIntervalDays;
document.querySelector('#upcomingEpisodeGracePeriodDays').value = config.UpcomingEpisodeGracePeriodDays;
document.querySelector('#hideMissingCastMembers').checked = config.HideMissingCastMembers;
document.querySelector('#hideMissingCrewMembers').checked = config.HideMissingCrewMembers;
populateMissingEpisodeLibraries(config.EnabledMissingEpisodeLibraries || []);
var maxCastMembers = document.querySelector('#maxCastMembers');
maxCastMembers.value = config.MaxCastMembers;
maxCastMembers.dispatchEvent(new Event('change', {
@@ -189,6 +253,17 @@
config.ExcludeTagsSeries = document.querySelector('#excludeTagsSeries').checked;
config.ExcludeTagsMovies = document.querySelector('#excludeTagsMovies').checked;
config.ImportSeasonName = document.querySelector('#importSeasonName').checked;
config.ImportUnairedEpisodes = document.querySelector('#importUnairedEpisodes').checked;
config.ImportMissingEpisodes = document.querySelector('#importMissingEpisodes').checked;
config.ImportSpecials = document.querySelector('#importSpecials').checked;
config.MissingEpisodeRefreshIntervalDays = parseInt(document.querySelector('#missingEpisodeRefreshIntervalDays').value, 10);
config.UpcomingEpisodeGracePeriodDays = parseInt(document.querySelector('#upcomingEpisodeGracePeriodDays').value, 10);
var libraryCheckboxes = document.querySelectorAll('.missingEpisodeLibrary');
if (libraryCheckboxes.length > 0) {
config.EnabledMissingEpisodeLibraries = Array.prototype.filter
.call(libraryCheckboxes, function (checkbox) { return checkbox.checked; })
.map(function (checkbox) { return checkbox.getAttribute('data-library-id'); });
}
config.MaxCastMembers = document.querySelector('#maxCastMembers').value;
config.MaxCrewMembers = document.querySelector('#maxCrewMembers').value;
config.HideMissingCastMembers = document.querySelector('#hideMissingCastMembers').checked;
@@ -0,0 +1,663 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using Microsoft.Extensions.Logging;
using TMDbLib.Objects.Search;
namespace MediaBrowser.Providers.Plugins.Tmdb.TV
{
/// <summary>
/// Creates virtual (metadata-only) entries for missing and unaired episodes.
/// </summary>
public class TmdbMissingEpisodeProvider : ICustomMetadataProvider<Series>, IHasItemChangeMonitor, IHasOrder
{
private readonly TmdbClientManager _tmdbClientManager;
private readonly ILibraryManager _libraryManager;
private readonly IFileSystem _fileSystem;
private readonly IProviderManager _providerManager;
private readonly ILogger<TmdbMissingEpisodeProvider> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TmdbMissingEpisodeProvider"/> class.
/// </summary>
/// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param>
/// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
/// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
/// <param name="providerManager">The <see cref="IProviderManager"/>.</param>
/// <param name="logger">The <see cref="ILogger{TmdbMissingEpisodeProvider}"/>.</param>
public TmdbMissingEpisodeProvider(
TmdbClientManager tmdbClientManager,
ILibraryManager libraryManager,
IFileSystem fileSystem,
IProviderManager providerManager,
ILogger<TmdbMissingEpisodeProvider> logger)
{
_tmdbClientManager = tmdbClientManager;
_libraryManager = libraryManager;
_fileSystem = fileSystem;
_providerManager = providerManager;
_logger = logger;
}
/// <inheritdoc />
public string Name => TmdbUtils.ProviderName;
/// <inheritdoc />
// Run after the remote series provider so the TMDb id and other metadata are available.
public int Order => 100;
/// <inheritdoc />
public bool HasChanged(BaseItem item, IDirectoryService directoryService)
{
// Reporting a change makes this provider (and only this provider) run during an otherwise incremental refresh.
if (Plugin.Instance?.Configuration is null)
{
return false;
}
return item is Series series && series.HasProviderId(MetadataProvider.Tmdb);
}
/// <inheritdoc />
public async Task<ItemUpdateType> FetchAsync(Series item, MetadataRefreshOptions options, CancellationToken cancellationToken)
{
var configuration = Plugin.Instance?.Configuration;
var importUnaired = (configuration?.ImportUnairedEpisodes).GetValueOrDefault();
var importMissing = (configuration?.ImportMissingEpisodes).GetValueOrDefault();
// The provider is inactive for this series when both global imports are off, or the series'
// library has not been opted in. In either case remove every virtual episode (unaired and
// missing alike) it previously created, so disabling the feature cleans up on the next scan.
if ((!importUnaired && !importMissing) || !IsEnabledForLibrary(item))
{
if (!PruneAllVirtualEpisodes(item))
{
return ItemUpdateType.None;
}
item.Children = null;
return ItemUpdateType.MetadataImport;
}
var tmdbId = item.GetProviderId(MetadataProvider.Tmdb);
if (string.IsNullOrEmpty(tmdbId)
|| !int.TryParse(tmdbId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var seriesTmdbId)
|| seriesTmdbId <= 0)
{
return ItemUpdateType.None;
}
var language = item.GetPreferredMetadataLanguage();
var countryCode = item.GetPreferredMetadataCountryCode();
var imageLanguages = TmdbUtils.GetImageLanguagesParam(language, countryCode);
var tmdbSeries = await _tmdbClientManager
.GetSeriesAsync(seriesTmdbId, language, imageLanguages, countryCode, cancellationToken)
.ConfigureAwait(false);
if (tmdbSeries?.Seasons is null)
{
return ItemUpdateType.None;
}
var today = DateTime.UtcNow.Date;
var importSpecials = (configuration?.ImportSpecials).GetValueOrDefault();
var gracePeriodDays = Math.Max(0, (configuration?.UpcomingEpisodeGracePeriodDays).GetValueOrDefault());
// Track every (season, episode) number that already exists (physical or virtual) so we never
// create a duplicate.
// When missing episodes are disabled, this pass also prunes virtual episodes that aired more
// than the grace period ago, as well as any specials when specials are not wanted.
var (existingEpisodes, updatableEpisodes) = GetExistingEpisodes(item, !importMissing, today, gracePeriodDays, importSpecials, out var prunedEpisodes);
var seasonsByNumber = item.GetRecursiveChildren(i => i is Season)
.OfType<Season>()
.Where(s => s.IndexNumber.HasValue)
.GroupBy(s => s.IndexNumber!.Value)
.ToDictionary(g => g.Key, g => g.First());
var addedEpisodes = false;
var updatedEpisodes = false;
foreach (var seasonInfo in tmdbSeries.Seasons)
{
cancellationToken.ThrowIfCancellationRequested();
var seasonNumber = seasonInfo.SeasonNumber;
var tmdbSeason = await _tmdbClientManager
.GetSeasonAsync(seriesTmdbId, seasonNumber, language, imageLanguages, countryCode, cancellationToken)
.ConfigureAwait(false);
if (tmdbSeason?.Episodes is null)
{
continue;
}
foreach (var tmdbEpisode in tmdbSeason.Episodes)
{
var episodeNumber = (int)tmdbEpisode.EpisodeNumber;
var premiereDate = GetPremiereDate(tmdbEpisode);
// Skips undated episodes, unaired (upcoming) ones unless upcoming import is enabled,
// already aired ones unless missing import is enabled, and unaired specials entirely.
if (!ShouldImportEpisode(premiereDate, today, importUnaired, importMissing, seasonNumber == 0, importSpecials))
{
continue;
}
var key = (seasonNumber, episodeNumber);
// Already have a virtual episode this provider created, keep metadata in sync with TMDb.
if (updatableEpisodes.TryGetValue(key, out var existingEpisode))
{
var season = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false);
var changed = UpdateVirtualEpisode(existingEpisode, tmdbEpisode, premiereDate);
if (!existingEpisode.ParentId.Equals(season.Id))
{
existingEpisode.SetParent(season);
existingEpisode.SeasonId = season.Id;
existingEpisode.SeasonName = season.Name;
changed = true;
}
if (string.IsNullOrEmpty(existingEpisode.PresentationUniqueKey))
{
existingEpisode.PresentationUniqueKey = existingEpisode.CreatePresentationUniqueKey();
changed = true;
}
if (changed)
{
await existingEpisode.UpdateToRepositoryAsync(ItemUpdateType.MetadataImport, cancellationToken).ConfigureAwait(false);
updatedEpisodes = true;
}
// Backfill the still for placeholders created before images were fetched.
if (await EnsureEpisodeImageAsync(existingEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false))
{
updatedEpisodes = true;
}
continue;
}
if (!existingEpisodes.Add(key))
{
continue;
}
var targetSeason = await GetOrCreateSeasonAsync(item, seasonNumber, tmdbSeason.Name, seasonsByNumber, cancellationToken).ConfigureAwait(false);
var newEpisode = AddVirtualEpisode(item, targetSeason, tmdbEpisode, premiereDate);
await EnsureEpisodeImageAsync(newEpisode, tmdbEpisode, cancellationToken).ConfigureAwait(false);
addedEpisodes = true;
}
}
var alignedSeasons = await AlignVirtualSeasonSortNamesAsync(seasonsByNumber.Values, cancellationToken).ConfigureAwait(false);
if (!addedEpisodes && !prunedEpisodes && !updatedEpisodes && !alignedSeasons)
{
return ItemUpdateType.None;
}
// Invalidate the cached children so that the season creation / cleanup that runs later in
// SeriesMetadataService.AfterMetadataRefresh observes the newly created (and pruned) episodes.
item.Children = null;
return ItemUpdateType.MetadataImport;
}
/// <summary>
/// Returns the series' season with the given number, creating (and refreshing) a virtual season
/// when the whole season is missing from the library.
/// </summary>
private async Task<Season> GetOrCreateSeasonAsync(Series series, int seasonNumber, string? seasonName, Dictionary<int, Season> seasonsByNumber, CancellationToken cancellationToken)
{
if (seasonsByNumber.TryGetValue(seasonNumber, out var existingSeason))
{
return existingSeason;
}
_logger.LogInformation("Creating virtual season {SeasonNumber} for series {SeriesName}", seasonNumber, series.Name);
var season = new Season
{
Name = seasonName,
IndexNumber = seasonNumber,
Id = _libraryManager.GetNewItemId(
series.Id.ToString("N", CultureInfo.InvariantCulture) + "Season" + seasonNumber.ToString(CultureInfo.InvariantCulture),
typeof(Season)),
IsVirtualItem = true,
SeriesId = series.Id,
SeriesName = series.Name,
SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
};
series.AddChild(season);
await season.RefreshMetadata(new MetadataRefreshOptions(new DirectoryService(_fileSystem)), cancellationToken).ConfigureAwait(false);
seasonsByNumber[seasonNumber] = season;
return season;
}
/// <summary>
/// Mirrors physical seasons' name-based sort convention onto virtual seasons so they interleave by
/// number instead of jumping ahead. See <see cref="BuildSeasonSortNameTemplate"/> for the details.
/// </summary>
/// <param name="seasons">The series' seasons (physical and virtual).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns><c>true</c> if any virtual season was updated; otherwise <c>false</c>.</returns>
private async Task<bool> AlignVirtualSeasonSortNamesAsync(IEnumerable<Season> seasons, CancellationToken cancellationToken)
{
var seasonList = seasons.ToList();
var template = BuildSeasonSortNameTemplate(seasonList);
if (template is null)
{
// No physical season sorts by name: virtual seasons already share the bare-index key space.
return false;
}
var updated = false;
foreach (var season in seasonList)
{
if (!season.IsVirtualItem || !season.IndexNumber.HasValue)
{
continue;
}
var desired = template(season.IndexNumber.Value);
if (string.Equals(season.ForcedSortName, desired, StringComparison.Ordinal))
{
continue;
}
_logger.LogInformation(
"Aligning sort name of virtual season {SeasonNumber} in series {SeriesName} to {SortName}",
season.IndexNumber,
season.SeriesName,
desired);
season.ForcedSortName = desired;
await season.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false);
updated = true;
}
return updated;
}
/// <summary>
/// Builds a factory that maps a season number to a forced sort name mirroring a physical,
/// name-sorted sibling season, or <c>null</c> when no physical season sorts by name.
/// </summary>
/// <param name="seasons">The series' seasons (physical and virtual).</param>
/// <returns>A season-number-to-sort-name factory, or <c>null</c> if there is nothing to mirror.</returns>
internal static Func<int, string>? BuildSeasonSortNameTemplate(IEnumerable<Season> seasons)
{
// Season.CreateSortName sorts by the bare padded index ("0003"), but season NFOs give physical
// seasons a name-based forced sort ("Season 01" -> "season 0000000001"). The digit-leading key
// sorts ahead of the letter-leading one, so mirror the sibling's token with each season number.
var reference = seasons.FirstOrDefault(s =>
!s.IsVirtualItem && s.IndexNumber.HasValue && !string.IsNullOrEmpty(s.ForcedSortName));
if (reference is null)
{
return null;
}
var forced = reference.ForcedSortName!;
// Locate the last run of digits (the season number) in the sibling's forced sort name.
var end = -1;
var start = -1;
for (var i = forced.Length - 1; i >= 0; i--)
{
if (char.IsDigit(forced[i]))
{
end = end < 0 ? i : end;
start = i;
}
else if (end >= 0)
{
break;
}
}
if (end < 0)
{
// Sibling has no numeric component to swap; leave virtual seasons on the bare-index key.
return null;
}
var prefix = forced[..start];
var suffix = forced[(end + 1)..];
var width = end - start + 1;
// The exact zero-padding is cosmetic: ModifySortChunks pads every digit run to 10 characters,
// so "Season 3" and "Season 03" collapse to the same sort key. Keeping the sibling's width just
// makes the stored value read naturally.
return number => prefix
+ number.ToString(CultureInfo.InvariantCulture).PadLeft(width, '0')
+ suffix;
}
private bool IsEnabledForLibrary(BaseItem item)
{
var enabledLibraries = Plugin.Instance?.Configuration.EnabledMissingEpisodeLibraries;
if (enabledLibraries is null || enabledLibraries.Length == 0)
{
return false;
}
// A series can live under more than one collection folder; opting in any one of them is
// enough. An item that belongs to no collection folder cannot be opted in at all.
return _libraryManager.GetCollectionFolders(item).Any(folder =>
enabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase));
}
private (HashSet<(int Season, int Episode)> Keys, Dictionary<(int Season, int Episode), Episode> Updatable) GetExistingEpisodes(Series series, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool importSpecials, out bool pruned)
{
var keys = new HashSet<(int Season, int Episode)>();
var updatable = new Dictionary<(int Season, int Episode), Episode>();
var physicalKeys = new HashSet<(int Season, int Episode)>();
var ourVirtuals = new List<((int Season, int Episode) Key, Episode Episode)>();
pruned = false;
// Enumerate by parent rather than via Series.GetEpisodes: on an initial scan the episodes'
// SeriesPresentationUniqueKey is not set yet, so the presentation-key based query would miss
// them. GetRecursiveChildren walks the actual child tree and sees them regardless.
foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType<Episode>())
{
// The series is refreshed before its episodes during an initial scan, so a freshly
// resolved physical episode may not have its numbers populated yet. Resolve them from
// the path (in memory, mirroring CreateSeasonsAsync) so we can dedupe against episodes
// the user actually has files for instead of creating virtual duplicates.
if (episode.IsFileProtocol && (!episode.ParentIndexNumber.HasValue || !episode.IndexNumber.HasValue))
{
try
{
_libraryManager.FillMissingEpisodeNumbersFromPath(episode, false);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error resolving episode number from path for {Path}", episode.Path);
}
}
// Virtual episodes this provider created are candidates for metadata sync (and pruning).
var isOurs = episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb);
if (ShouldPrune(episode, pruneAgedOut, today, gracePeriodDays, importSpecials))
{
DeleteEpisode(episode, "no longer upcoming and missing episodes are disabled");
pruned = true;
continue;
}
if (episode.ParentIndexNumber.HasValue && episode.IndexNumber.HasValue)
{
var key = (episode.ParentIndexNumber.Value, episode.IndexNumber.Value);
keys.Add(key);
// Defer the ours/physical reconciliation: an episode's virtual counterpart and its
// physical file can appear in either order while walking the tree, so we can only
// decide which of our virtual episodes are superseded once every episode is seen.
if (isOurs)
{
ourVirtuals.Add((key, episode));
}
else if (!episode.IsVirtualItem)
{
physicalKeys.Add(key);
}
}
}
// A physical file now exists for one of our placeholders: delete the placeholder here rather
// than updating it (and then leaving RemoveObsoleteEpisodes to delete it moments later). The
// physical key already blocks re-creation via the dedupe set above.
foreach (var (key, episode) in ourVirtuals)
{
if (physicalKeys.Contains(key))
{
DeleteEpisode(episode, "a physical episode now exists for this slot");
pruned = true;
}
else
{
// Virtual episodes this provider created are candidates for metadata sync.
updatable[key] = episode;
}
}
return (keys, updatable);
}
/// <summary>
/// Removes every virtual episode this provider previously created in the series.
/// </summary>
/// <param name="series">The series to clean up.</param>
/// <returns><c>true</c> if any episode was removed; otherwise <c>false</c>.</returns>
private bool PruneAllVirtualEpisodes(Series series)
{
var pruned = false;
foreach (var episode in series.GetRecursiveChildren(i => i is Episode).OfType<Episode>())
{
if (episode.IsVirtualItem && episode.HasProviderId(MetadataProvider.Tmdb))
{
DeleteEpisode(episode, "the TMDb missing episode provider is disabled for this library");
pruned = true;
}
}
return pruned;
}
private void DeleteEpisode(Episode episode, string reason)
{
_logger.LogInformation(
"Removing virtual episode S{SeasonNumber}E{EpisodeNumber} in series {SeriesName}: {Reason}",
episode.ParentIndexNumber,
episode.IndexNumber,
episode.SeriesName,
reason);
_libraryManager.DeleteItem(
episode,
new DeleteOptions { DeleteFileLocation = false },
false);
}
/// <summary>
/// Determines whether a TMDb episode should be imported as a virtual item, based on its air date
/// and the enabled options. Undated episodes are never imported; unaired (today or later) episodes
/// require <paramref name="importUnaired"/>; already aired episodes require <paramref name="importMissing"/>.
/// Specials (season 0) are only imported when <paramref name="importSpecials"/> is enabled.
/// </summary>
/// <param name="premiereDate">The episode air date (UTC), or null if unknown.</param>
/// <param name="today">The current UTC date.</param>
/// <param name="importUnaired">Whether unaired (upcoming) episodes should be imported.</param>
/// <param name="importMissing">Whether already aired missing episodes should be imported.</param>
/// <param name="isSpecial">Whether the episode belongs to the specials season (season 0).</param>
/// <param name="importSpecials">Whether specials should be included.</param>
/// <returns><c>true</c> if the episode should be imported; otherwise <c>false</c>.</returns>
internal static bool ShouldImportEpisode(DateTime? premiereDate, DateTime today, bool importUnaired, bool importMissing, bool isSpecial, bool importSpecials)
{
if (!premiereDate.HasValue)
{
return false;
}
// Specials are only imported when the user opts in.
if (isSpecial && !importSpecials)
{
return false;
}
var isUnaired = premiereDate.Value.Date >= today;
return isUnaired ? importUnaired : importMissing;
}
/// <summary>
/// Determines whether an existing virtual episode created by this provider (carries a TMDb id)
/// should be pruned. Specials are removed entirely unless <paramref name="importSpecials"/> is
/// enabled. Otherwise, when missing episodes are not wanted, an entry is pruned once its air date
/// is more than <paramref name="gracePeriodDays"/> in the past; the grace period keeps recently
/// aired episodes in place to allow for the delay between an episode airing and its file being
/// added to the library.
/// </summary>
/// <param name="episode">The episode to evaluate.</param>
/// <param name="pruneAgedOut">Whether aged-out virtual episodes should be pruned (missing import disabled).</param>
/// <param name="today">The current UTC date.</param>
/// <param name="gracePeriodDays">The number of days an aired episode is retained before pruning.</param>
/// <param name="importSpecials">Whether specials should be kept.</param>
/// <returns><c>true</c> if the episode should be pruned; otherwise <c>false</c>.</returns>
internal static bool ShouldPrune(Episode episode, bool pruneAgedOut, DateTime today, int gracePeriodDays, bool importSpecials)
{
if (!episode.IsVirtualItem || !episode.HasProviderId(MetadataProvider.Tmdb))
{
return false;
}
// Specials are removed entirely unless the user opts in.
if (episode.ParentIndexNumber == 0 && !importSpecials)
{
return true;
}
// When missing episodes are not wanted, prune placeholders for episodes that aired more than
// the grace period ago.
return pruneAgedOut
&& episode.PremiereDate.HasValue
&& episode.PremiereDate.Value.Date < today.AddDays(-gracePeriodDays);
}
internal static DateTime? GetPremiereDate(TvSeasonEpisode tmdbEpisode)
{
return tmdbEpisode.AirDate.HasValue
? DateTime.SpecifyKind(tmdbEpisode.AirDate.Value, DateTimeKind.Local).ToUniversalTime()
: null;
}
internal static bool UpdateVirtualEpisode(Episode episode, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate)
{
var changed = false;
if (!string.IsNullOrEmpty(tmdbEpisode.Name) && !string.Equals(episode.Name, tmdbEpisode.Name, StringComparison.Ordinal))
{
episode.Name = tmdbEpisode.Name;
changed = true;
}
if (!string.IsNullOrEmpty(tmdbEpisode.Overview) && !string.Equals(episode.Overview, tmdbEpisode.Overview, StringComparison.Ordinal))
{
episode.Overview = tmdbEpisode.Overview;
changed = true;
}
if (premiereDate.HasValue && episode.PremiereDate != premiereDate)
{
episode.PremiereDate = premiereDate;
episode.ProductionYear = tmdbEpisode.AirDate?.Year;
changed = true;
}
return changed;
}
private Episode AddVirtualEpisode(Series series, Season season, TvSeasonEpisode tmdbEpisode, DateTime? premiereDate)
{
var seasonNumber = season.IndexNumber.GetValueOrDefault();
var episodeNumber = (int)tmdbEpisode.EpisodeNumber;
// Leaving Path unset makes the item a virtual (metadata-only) episode.
var episode = new Episode
{
Name = tmdbEpisode.Name,
IndexNumber = episodeNumber,
ParentIndexNumber = seasonNumber,
Id = _libraryManager.GetNewItemId(
series.Id.ToString("N", CultureInfo.InvariantCulture)
+ "Season" + seasonNumber.ToString(CultureInfo.InvariantCulture)
+ "Episode" + episodeNumber.ToString(CultureInfo.InvariantCulture),
typeof(Episode)),
IsVirtualItem = true,
PremiereDate = premiereDate,
ProductionYear = tmdbEpisode.AirDate?.Year,
Overview = tmdbEpisode.Overview,
SeasonId = season.Id,
SeasonName = season.Name,
SeriesId = series.Id,
SeriesName = series.Name,
SeriesPresentationUniqueKey = series.GetPresentationUniqueKey()
};
episode.PresentationUniqueKey = episode.CreatePresentationUniqueKey();
if (tmdbEpisode.Id > 0)
{
episode.SetProviderId(MetadataProvider.Tmdb, tmdbEpisode.Id.ToString(CultureInfo.InvariantCulture));
}
_logger.LogInformation(
"Creating virtual episode S{SeasonNumber}E{EpisodeNumber} for series {SeriesName}",
seasonNumber,
episodeNumber,
series.Name);
season.AddChild(episode);
return episode;
}
/// <summary>
/// Downloads the TMDb still for a virtual episode that has no image yet, so it does not fall back
/// to the season/series image.
/// </summary>
/// <param name="episode">The virtual episode.</param>
/// <param name="tmdbEpisode">The matching TMDb episode.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns><c>true</c> if a still was downloaded and saved; otherwise <c>false</c>.</returns>
private async Task<bool> EnsureEpisodeImageAsync(Episode episode, TvSeasonEpisode tmdbEpisode, CancellationToken cancellationToken)
{
// The still ships with the season episode list, so use it directly instead of a per-episode lookup.
if (episode.HasImage(ImageType.Primary, 0) || string.IsNullOrEmpty(tmdbEpisode.StillPath))
{
return false;
}
var stillUrl = _tmdbClientManager.GetStillUrl(tmdbEpisode.StillPath);
if (string.IsNullOrEmpty(stillUrl))
{
return false;
}
try
{
// SaveImage sets the image path on the item but does not persist it, so save afterwards.
await _providerManager.SaveImage(episode, stillUrl, ImageType.Primary, null, cancellationToken).ConfigureAwait(false);
await episode.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, cancellationToken).ConfigureAwait(false);
return true;
}
catch (Exception ex)
{
_logger.LogError(
ex,
"Error downloading still for virtual episode S{SeasonNumber}E{EpisodeNumber} of {SeriesName}",
episode.ParentIndexNumber,
episode.IndexNumber,
episode.SeriesName);
return false;
}
}
}
}
@@ -76,11 +76,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV
result.Item.Name = seasonResult.Name;
}
result.Item.TrySetProviderId(MetadataProvider.Tmdb, seasonResult.Id?.ToString(CultureInfo.InvariantCulture));
result.Item.TrySetProviderId(MetadataProvider.Tvdb, seasonResult.ExternalIds?.TvdbId);
// TODO why was this disabled?
var credits = seasonResult.Credits;
if (credits?.Cast is not null)
{
var castQuery = config.HideMissingCastMembers
@@ -0,0 +1,207 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.Providers;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.IO;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace MediaBrowser.Providers.Plugins.Tmdb.TV
{
/// <summary>
/// Scheduled task that re-checks TMDb for newly announced unaired and missing episodes and creates
/// the corresponding virtual items. This keeps the "Upcoming" view current for series whose local
/// files have not changed, which an ordinary library scan would never re-examine.
/// </summary>
public class TmdbUpcomingEpisodesTask : IScheduledTask
{
private const int DefaultIntervalDays = 7;
private readonly ILibraryManager _libraryManager;
private readonly IFileSystem _fileSystem;
private readonly ILogger<TmdbUpcomingEpisodesTask> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="TmdbUpcomingEpisodesTask"/> class.
/// </summary>
/// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param>
/// <param name="fileSystem">The <see cref="IFileSystem"/>.</param>
/// <param name="logger">The <see cref="ILogger{TmdbUpcomingEpisodesTask}"/>.</param>
public TmdbUpcomingEpisodesTask(
ILibraryManager libraryManager,
IFileSystem fileSystem,
ILogger<TmdbUpcomingEpisodesTask> logger)
{
_libraryManager = libraryManager;
_fileSystem = fileSystem;
_logger = logger;
}
/// <inheritdoc />
public string Name => "Refresh upcoming and missing episodes (TheMovieDb)";
/// <inheritdoc />
public string Description => "Checks TheMovieDb for newly announced episodes and creates virtual entries for unaired and missing episodes, according to the TMDb plugin settings. When both options are disabled, removes any virtual entries previously created.";
/// <inheritdoc />
public string Category => "Library";
/// <inheritdoc />
public string Key => "TmdbRefreshUpcomingEpisodes";
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
var intervalDays = Plugin.Instance?.Configuration.MissingEpisodeRefreshIntervalDays ?? DefaultIntervalDays;
if (intervalDays <= 0)
{
intervalDays = DefaultIntervalDays;
}
yield return new TaskTriggerInfo
{
Type = TaskTriggerInfoType.IntervalTrigger,
IntervalTicks = TimeSpan.FromDays(intervalDays).Ticks
};
}
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var configuration = Plugin.Instance?.Configuration;
if (configuration is null)
{
progress.Report(100);
return;
}
// The feature is fully disabled: remove every virtual episode (and now-empty virtual season)
// this provider previously created, across all libraries, then stop.
if ((!configuration.ImportUnairedEpisodes && !configuration.ImportMissingEpisodes)
|| configuration.EnabledMissingEpisodeLibraries.Length == 0)
{
RemoveAllVirtualItems(progress, cancellationToken);
return;
}
// Process non-ended series (they may have gained episodes) plus any series in a library that
// is not opted in (regardless of status) so the provider can prune the virtual episodes it
// previously created there. Ended series in enabled libraries cannot change, so they're skipped.
var series = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Series],
Recursive = true
})
.OfType<Series>()
.Where(s => s.HasProviderId(MetadataProvider.Tmdb)
&& (s.Status != SeriesStatus.Ended || !IsEnabledForLibrary(s)))
.ToList();
if (series.Count == 0)
{
progress.Report(100);
return;
}
// ValidateChildren (rather than a bare RefreshMetadata) is required so the created episodes
// are immediately visible.
var refreshOptions = new MetadataRefreshOptions(new DirectoryService(_fileSystem))
{
MetadataRefreshMode = MetadataRefreshMode.Default,
ImageRefreshMode = MetadataRefreshMode.ValidationOnly,
IsAutomated = true
};
for (var i = 0; i < series.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
await series[i].ValidateChildren(new Progress<double>(), refreshOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error refreshing upcoming episodes for series {SeriesName}", series[i].Name);
}
progress.Report(100.0 * (i + 1) / series.Count);
}
}
private bool IsEnabledForLibrary(BaseItem item)
{
var enabledLibraries = Plugin.Instance?.Configuration.EnabledMissingEpisodeLibraries;
if (enabledLibraries is null || enabledLibraries.Length == 0)
{
return false;
}
// A series can live under more than one collection folder; opting in any one of them is
// enough. An item that belongs to no collection folder cannot be opted in at all.
return _libraryManager.GetCollectionFolders(item).Any(folder =>
enabledLibraries.Contains(folder.Id.ToString("N", CultureInfo.InvariantCulture), StringComparer.OrdinalIgnoreCase));
}
/// <summary>
/// Removes every virtual episode this provider created (identified by being virtual and carrying
/// a TMDb id), plus any virtual season left without episodes as a result. Used when both import
/// options are disabled so turning the feature off cleans up its placeholders.
/// </summary>
private void RemoveAllVirtualItems(IProgress<double> progress, CancellationToken cancellationToken)
{
var deleteOptions = new DeleteOptions { DeleteFileLocation = false };
var virtualEpisodes = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Episode],
IsVirtualItem = true,
HasTmdbId = true,
Recursive = true
});
for (var i = 0; i < virtualEpisodes.Count; i++)
{
cancellationToken.ThrowIfCancellationRequested();
_logger.LogInformation("Removing virtual episode {Name}: the TMDb missing episode provider is disabled", virtualEpisodes[i].Name);
_libraryManager.DeleteItem(virtualEpisodes[i], deleteOptions, false);
progress.Report(95.0 * (i + 1) / virtualEpisodes.Count);
}
// Remove virtual seasons that are now empty (mirrors the cleanup an ordinary series refresh does).
var virtualSeasons = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = [BaseItemKind.Season],
IsVirtualItem = true,
HasTmdbId = true,
Recursive = true
});
foreach (var season in virtualSeasons.OfType<Season>())
{
cancellationToken.ThrowIfCancellationRequested();
if (season.GetEpisodes().Count == 0)
{
_libraryManager.DeleteItem(season, deleteOptions, false);
}
}
progress.Report(100);
}
}
}
@@ -591,6 +591,16 @@ namespace MediaBrowser.Providers.Plugins.Tmdb
return GetUrl(Plugin.Instance.Configuration.ProfileSize, actorProfilePath);
}
/// <summary>
/// Gets the absolute URL of an episode still.
/// </summary>
/// <param name="stillPath">The relative URL of the still.</param>
/// <returns>The absolute URL.</returns>
public string? GetStillUrl(string? stillPath)
{
return GetUrl(Plugin.Instance.Configuration.StillSize, stillPath);
}
/// <summary>
/// Converts poster <see cref="ImageData"/>s into <see cref="RemoteImageInfo"/>s.
/// </summary>
@@ -82,7 +82,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
writer.WriteElementString("title", album.Name);
}
if (album.ProductionYear.HasValue)
if (album.ProductionYear is not null)
{
writer.WriteElementString("year", album.ProductionYear.Value.ToString(CultureInfo.InvariantCulture));
}
@@ -544,7 +544,7 @@ namespace MediaBrowser.XbmcMetadata.Savers
writer.WriteElementString("rating", item.CommunityRating.Value.ToString(CultureInfo.InvariantCulture));
}
if (item.ProductionYear.HasValue)
if (item.ProductionYear is not null)
{
writer.WriteElementString("year", item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture));
}
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Expressions;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.MatchCriteria;
@@ -12,6 +13,14 @@ namespace Jellyfin.Database.Implementations;
/// </summary>
public static class DescendantQueryHelper
{
/// <summary>
/// Gets the predicate identifying items that count toward played/total aggregation:
/// real leaf media, i.e. neither folders nor virtual items (missing or unaired episodes).
/// Shared by the per-item and batched count paths so they cannot diverge.
/// </summary>
public static Expression<Func<BaseItemEntity, bool>> IsCountableLeaf { get; } =
b => !b.IsFolder && !b.IsVirtualItem;
/// <summary>
/// Gets a queryable of all descendant IDs for a parent item.
/// Traverses AncestorIds and LinkedChildren to find all descendants.
@@ -25,7 +25,7 @@ public class LinkedChildEntity
/// <summary>
/// Gets or sets the sort order.
/// </summary>
public int? SortOrder { get; set; }
public int SortOrder { get; set; }
/// <summary>
/// Gets or sets the parent item navigation property.
@@ -70,15 +70,24 @@ public static class JellyfinQueryHelperExtensions
bool invert = false)
{
var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id);
var typeFilter = OneOrManyExpressionBuilder<ItemValue, ItemValueType>(itemValueTypes, iv => iv.Type);
var typeFilter = OneOrManyExpressionBuilder<ItemValueMap, ItemValueType>(itemValueTypes, m => m.ItemValue.Type);
return baseQuery.Where(item =>
context.ItemValues
.Where(typeFilter)
.Join(context.ItemValuesMap, e => e.ItemValueId, e => e.ItemValueId, (itemVal, map) => new { itemVal, map })
.Any(val =>
context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.itemVal.CleanValue)
&& val.map.ItemId == item.Id) == EF.Constant(!invert));
// Flat sub-selects + Contains instead of a nested correlated .Any(...Any(...)).
var referencedCleanValues = context.BaseItems
.Where(itemFilter)
.Select(e => e.CleanName);
var matchingItemIds = context.ItemValuesMap
.Where(typeFilter)
.Where(m => referencedCleanValues.Contains(m.ItemValue.CleanValue))
.Select(m => m.ItemId);
if (invert)
{
return baseQuery.Where(e => !matchingItemIds.Contains(e.Id));
}
return baseQuery.Where(e => matchingItemIds.Contains(e.Id));
}
/// <summary>
@@ -102,13 +111,21 @@ public static class JellyfinQueryHelperExtensions
var itemFilter = OneOrManyExpressionBuilder<BaseItemEntity, Guid>(referenceIds, f => f.Id);
return item =>
context.ItemValues
.Join(context.ItemValuesMap, e => e.ItemValueId, e => e.ItemValueId, (item, map) => new { item, map })
.Any(val =>
val.item.Type == itemValueType
&& context.BaseItems.Where(itemFilter).Any(e => e.CleanName == val.item.CleanValue)
&& val.map.ItemId == item.Id) == EF.Constant(!invert);
// Flat sub-selects + Contains instead of a nested correlated .Any(...Any(...)).
var referencedCleanValues = context.BaseItems
.Where(itemFilter)
.Select(e => e.CleanName);
var matchingItemIds = context.ItemValuesMap
.Where(m => m.ItemValue.Type == itemValueType && referencedCleanValues.Contains(m.ItemValue.CleanValue))
.Select(m => m.ItemId);
if (invert)
{
return item => !matchingItemIds.Contains(item.Id);
}
return item => matchingItemIds.Contains(item.Id);
}
/// <summary>
@@ -224,14 +241,14 @@ public static class JellyfinQueryHelperExtensions
var containsMethodInfo = _containsQueryCache.GetOrAdd(typeof(TProperty), static (key) => _containsMethodGenericCache.MakeGenericMethod(key));
// Threshold picked from microbenchmarks on SQLite: inline IN(const,...) beats a
// parameterized array lookup by ~5-10% up to ~32 elements.
if (oneOf.Count <= 32)
{
return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Constant(oneOf), property.Body), parameter);
}
return Expression.Lambda<Func<TEntity, bool>>(Expression.Call(null, containsMethodInfo, Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)), property.Body), parameter);
// Always wrap the collection in EF.Parameter so EF Core caches a single compiled plan and reuses it across calls.
return Expression.Lambda<Func<TEntity, bool>>(
Expression.Call(
null,
containsMethodInfo,
Expression.Call(null, _efParameterInstruction.MakeGenericMethod(oneOf.GetType()), Expression.Constant(oneOf)),
property.Body),
parameter);
}
internal static class ParameterReplacer
@@ -61,6 +61,11 @@ public class BaseItemConfiguration : IEntityTypeConfiguration<BaseItemEntity>
builder.HasIndex(e => new { e.TopParentId, e.MediaType, e.IsVirtualItem, e.DateCreated });
// resume
builder.HasIndex(e => new { e.MediaType, e.TopParentId, e.IsVirtualItem, e.PresentationUniqueKey });
// alternate versions of an item, e.g. resolving the played date of a version onto its primary.
// Filtered: almost no item has a primary version, and an index covering those rows too would tempt
// the planner into serving "PrimaryVersionId IS NULL" - true for the whole library - out of it.
builder.HasIndex(e => e.PrimaryVersionId)
.HasFilter("\"PrimaryVersionId\" IS NOT NULL");
// sorted library queries (e.g., Series sorted by SortName)
builder.HasIndex(e => new { e.Type, e.TopParentId, e.SortName });
// NextUp: per-series episode ordering (index seek + range scan on season/episode)
@@ -13,8 +13,7 @@ public class LinkedChildConfiguration : IEntityTypeConfiguration<LinkedChildEnti
public void Configure(EntityTypeBuilder<LinkedChildEntity> builder)
{
builder.ToTable("LinkedChildren");
builder.HasKey(e => new { e.ParentId, e.ChildId });
builder.HasIndex(e => new { e.ParentId, e.SortOrder });
builder.HasKey(e => new { e.ParentId, e.SortOrder });
builder.HasIndex(e => new { e.ParentId, e.ChildType });
builder.HasIndex(e => new { e.ChildId, e.ChildType });
@@ -0,0 +1,89 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Database.Providers.Sqlite.Migrations
{
/// <inheritdoc />
public partial class AllowDuplicatePlaylistChildren : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// Rows that predate the composite (ParentId, SortOrder) primary key stored a null SortOrder
// (e.g. BoxSet and Collection children). Assign each such row a stable 0-based position within
// its parent so the rows stay unique once SortOrder becomes part of the primary key; otherwise
// they would all collapse to the column default (0) and collide during the table rebuild.
migrationBuilder.Sql(
@"UPDATE ""LinkedChildren""
SET ""SortOrder"" = (
SELECT COUNT(*)
FROM ""LinkedChildren"" AS lc2
WHERE lc2.""ParentId"" = ""LinkedChildren"".""ParentId""
AND lc2.""rowid"" < ""LinkedChildren"".""rowid""
)
WHERE ""SortOrder"" IS NULL;");
migrationBuilder.DropPrimaryKey(
name: "PK_LinkedChildren",
table: "LinkedChildren");
migrationBuilder.DropIndex(
name: "IX_LinkedChildren_ParentId_SortOrder",
table: "LinkedChildren");
migrationBuilder.AlterColumn<int>(
name: "SortOrder",
table: "LinkedChildren",
type: "INTEGER",
nullable: false,
defaultValue: 0,
oldClrType: typeof(int),
oldType: "INTEGER",
oldNullable: true);
migrationBuilder.AddPrimaryKey(
name: "PK_LinkedChildren",
table: "LinkedChildren",
columns: new[] { "ParentId", "SortOrder" });
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
// The (ParentId, ChildId) primary key cannot represent the same child more than once per
// parent. Drop any duplicate entries (keeping the first by SortOrder) that may have been
// created while duplicates were allowed, so the old key can be restored. This is lossy by
// nature — duplicate playlist entries cannot survive a downgrade.
migrationBuilder.Sql(
@"DELETE FROM ""LinkedChildren""
WHERE ""rowid"" NOT IN (
SELECT MIN(""rowid"")
FROM ""LinkedChildren""
GROUP BY ""ParentId"", ""ChildId""
);");
migrationBuilder.DropPrimaryKey(
name: "PK_LinkedChildren",
table: "LinkedChildren");
migrationBuilder.AlterColumn<int>(
name: "SortOrder",
table: "LinkedChildren",
type: "INTEGER",
nullable: true,
oldClrType: typeof(int),
oldType: "INTEGER");
migrationBuilder.AddPrimaryKey(
name: "PK_LinkedChildren",
table: "LinkedChildren",
columns: new[] { "ParentId", "ChildId" });
migrationBuilder.CreateIndex(
name: "IX_LinkedChildren_ParentId_SortOrder",
table: "LinkedChildren",
columns: new[] { "ParentId", "SortOrder" });
}
}
}
@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Jellyfin.Server.Implementations.Migrations
{
/// <inheritdoc />
public partial class AddPrimaryVersionIdIndex : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateIndex(
name: "IX_BaseItems_PrimaryVersionId",
table: "BaseItems",
column: "PrimaryVersionId",
filter: "\"PrimaryVersionId\" IS NOT NULL");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_BaseItems_PrimaryVersionId",
table: "BaseItems");
}
}
}
@@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.12");
modelBuilder.HasAnnotation("ProductVersion", "10.0.10");
modelBuilder.Entity("Jellyfin.Database.Implementations.Entities.AccessSchedule", b =>
{
@@ -373,6 +373,9 @@ namespace Jellyfin.Server.Implementations.Migrations
b.HasIndex("PresentationUniqueKey");
b.HasIndex("PrimaryVersionId")
.HasFilter("\"PrimaryVersionId\" IS NOT NULL");
b.HasIndex("SeasonId");
b.HasIndex("SeriesId");
@@ -812,23 +815,21 @@ namespace Jellyfin.Server.Implementations.Migrations
b.Property<Guid>("ParentId")
.HasColumnType("TEXT");
b.Property<int>("SortOrder")
.HasColumnType("INTEGER");
b.Property<Guid>("ChildId")
.HasColumnType("TEXT");
b.Property<int>("ChildType")
.HasColumnType("INTEGER");
b.Property<int?>("SortOrder")
.HasColumnType("INTEGER");
b.HasKey("ParentId", "ChildId");
b.HasKey("ParentId", "SortOrder");
b.HasIndex("ChildId", "ChildType");
b.HasIndex("ParentId", "ChildType");
b.HasIndex("ParentId", "SortOrder");
b.ToTable("LinkedChildren", (string)null);
b.HasAnnotation("Sqlite:UseSqlReturningClause", false);
@@ -497,7 +497,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
// trim trailing period from the folder name
var folderName = _fileSystem.GetValidFilename(timer.Name).Trim().TrimEnd('.').Trim();
if (metadata is not null && metadata.ProductionYear.HasValue)
if (metadata is not null && metadata.ProductionYear is not null)
{
folderName += " (" + metadata.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
}
@@ -532,7 +532,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
}
var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
if (timer.ProductionYear.HasValue)
if (timer.ProductionYear is not null)
{
folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
}
@@ -550,7 +550,7 @@ public sealed class RecordingsManager : IRecordingsManager, IDisposable
}
var folderName = _fileSystem.GetValidFilename(timer.Name).Trim();
if (timer.ProductionYear.HasValue)
if (timer.ProductionYear is not null)
{
folderName += " (" + timer.ProductionYear.Value.ToString(CultureInfo.InvariantCulture) + ")";
}
@@ -290,7 +290,7 @@ public class RecordingsMetadataManager
null,
DateTime.UtcNow.ToString(DateAddedFormat, CultureInfo.InvariantCulture)).ConfigureAwait(false);
if (item.ProductionYear.HasValue)
if (item.ProductionYear is not null)
{
await writer.WriteElementStringAsync(null, "year", null, item.ProductionYear.Value.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false);
}
@@ -491,6 +491,7 @@ public class NetworkManager : INetworkManager, IDisposable
startupOverrideKey,
true,
true));
WarnIfPublishedUrlBasePathDiffers(publishedServerUrls, config.BaseUrl);
_publishedServerUrls = publishedServerUrls;
return;
}
@@ -580,10 +581,53 @@ public class NetworkManager : INetworkManager, IDisposable
}
}
WarnIfPublishedUrlBasePathDiffers(publishedServerUrls, config.BaseUrl);
_publishedServerUrls = publishedServerUrls;
}
}
/// <summary>
/// Warns when a full-URL published server override uses a public path that differs from the configured base
/// URL. Jellyfin appends the base URL to generated Live TV client URLs in this case, which can conflict with
/// reverse proxies that translate public request paths. Bare host/IP overrides are exempt because the base URL
/// is appended when the API URL is built from them.
/// </summary>
/// <param name="publishedServerUrls">The parsed published server URL overrides.</param>
/// <param name="baseUrl">The configured base URL, if any.</param>
private void WarnIfPublishedUrlBasePathDiffers(List<PublishedServerUriOverride> publishedServerUrls, string baseUrl)
{
if (string.IsNullOrEmpty(baseUrl))
{
return;
}
foreach (var overrideUri in publishedServerUrls.Select(x => x.OverrideUri).Distinct(StringComparer.OrdinalIgnoreCase))
{
if (!overrideUri.StartsWith("http://", StringComparison.OrdinalIgnoreCase)
&& !overrideUri.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
continue;
}
if (!Uri.TryCreate(overrideUri, UriKind.Absolute, out var uri))
{
continue;
}
var path = Uri.UnescapeDataString(uri.AbsolutePath).TrimEnd('/');
if (path.EndsWith(baseUrl, StringComparison.OrdinalIgnoreCase))
{
continue;
}
var publishedServerHost = uri.GetComponents(UriComponents.HostAndPort, UriFormat.Unescaped);
_logger.LogWarning(
"The published server URL for host '{PublishedServerHost}' does not end with the configured base URL '{BaseUrl}'. Jellyfin will append this base URL when generating Live TV client URLs. If your reverse proxy translates public paths, this may cause Live TV playback to fail. Update the Published Server URIs setting on the Networking page of the admin dashboard, the JELLYFIN_PublishedServerUrl environment variable / --published-server-url option, or the reverse proxy path mapping accordingly.",
publishedServerHost,
baseUrl);
}
}
private void ConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs evt)
{
if (evt.Key.Equals(NetworkConfigurationStore.StoreKey, StringComparison.Ordinal))
@@ -851,7 +895,7 @@ public class NetworkManager : INetworkManager, IDisposable
bool isExternal = !IsInLocalNetwork(source);
_logger.LogDebug("Trying to get bind address for source {Source} - External: {IsExternal}", source, isExternal);
if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result))
if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result, out port))
{
return result;
}
@@ -1017,11 +1061,12 @@ public class NetworkManager : INetworkManager, IDisposable
/// <param name="source">IP source address to use.</param>
/// <param name="isInExternalSubnet">True if the source is in an external subnet.</param>
/// <param name="bindPreference">The published server URL that matches the source address.</param>
/// <param name="port">The explicit port parsed from the override, if any.</param>
/// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns>
private bool MatchesPublishedServerUrl(IPAddress source, bool isInExternalSubnet, out string bindPreference)
private bool MatchesPublishedServerUrl(IPAddress source, bool isInExternalSubnet, out string bindPreference, out int? port)
{
bindPreference = string.Empty;
int? port = null;
port = null;
// Only consider subnets including the source IP, preferring specific overrides
List<PublishedServerUriOverride> validPublishedServerUrls;
@@ -1063,24 +1108,42 @@ public class NetworkManager : INetworkManager, IDisposable
return false;
}
// Handle override specifying port
var parts = bindPreference.Split(':');
if (parts.Length > 1)
// Handle override specifying an explicit port.
(bindPreference, port) = ParseHostAndPort(bindPreference);
if (port.HasValue)
{
if (int.TryParse(parts[1], out int p))
{
bindPreference = parts[0];
port = p;
_logger.LogDebug("{Source}: Matching bind address override found: {Address}:{Port}", source, bindPreference, port);
return true;
}
_logger.LogDebug("{Source}: Matching bind address override found: {Address}:{Port}", source, bindPreference, port);
}
else
{
_logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference);
}
_logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference);
return true;
}
/// <summary>
/// Splits a published server URL override into its host and explicit port, if any.
/// Full URLs (containing "://") are returned whole, with any port left embedded.
/// </summary>
/// <param name="value">The override value, e.g. "host:port", "[::1]:port", or a full URL.</param>
/// <returns>The parsed host (or the original value if not split) and the explicit port, if any.</returns>
private static (string Host, int? Port) ParseHostAndPort(string value)
{
if (value.Contains("://", StringComparison.Ordinal))
{
return (value, null);
}
if (Uri.TryCreate("any://" + value, UriKind.Absolute, out var parsed) && parsed.Port != -1)
{
return (parsed.DnsSafeHost, parsed.Port);
}
return (value, null);
}
/// <summary>
/// Attempts to match the source against the user defined bind interfaces.
/// </summary>
@@ -1,13 +1,21 @@
using System;
using System.Globalization;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Api.Helpers;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Common.Net;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Devices;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.Movies;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.MediaEncoding;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.MediaInfo;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using Moq;
using Xunit;
@@ -16,17 +24,28 @@ namespace Jellyfin.Api.Tests.Helpers
{
public class MediaInfoHelperTests
{
private static MediaInfoHelper CreateHelper()
private const string LiveStreamFilesPath = "/LiveTv/LiveStreamFiles/abc/stream.ts";
private static MediaInfoHelper CreateHelper(
IMediaSourceManager? mediaSourceManager = null,
IServerApplicationHost? appHost = null,
string baseUrl = "")
{
var serverConfigurationManager = new Mock<IServerConfigurationManager>();
serverConfigurationManager
.Setup(x => x.GetConfiguration(It.IsAny<string>()))
.Returns(new NetworkConfiguration { BaseUrl = baseUrl });
return new MediaInfoHelper(
Mock.Of<IUserManager>(),
Mock.Of<ILibraryManager>(),
Mock.Of<IMediaSourceManager>(),
mediaSourceManager ?? Mock.Of<IMediaSourceManager>(),
Mock.Of<IMediaEncoder>(),
Mock.Of<IServerConfigurationManager>(),
serverConfigurationManager.Object,
Mock.Of<ILogger<MediaInfoHelper>>(),
Mock.Of<INetworkManager>(),
Mock.Of<IDeviceManager>());
Mock.Of<IDeviceManager>(),
appHost ?? Mock.Of<IServerApplicationHost>());
}
private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true)
@@ -95,5 +114,403 @@ namespace Jellyfin.Api.Tests.Helpers
Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
}
[Fact]
public async Task GetPlaybackInfo_ExistingLiveStream_RewritesReturnedCloneOnly()
{
const string LocalPath = "http://172.19.0.3:8096" + LiveStreamFilesPath;
var sharedLiveSource = new MediaSourceInfo
{
Id = "abc",
Protocol = MediaProtocol.Http,
Path = LocalPath,
LiveStreamId = "livestream-1"
};
var mediaSourceManager = new Mock<IMediaSourceManager>();
mediaSourceManager
.Setup(x => x.GetLiveStream(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(sharedLiveSource);
var appHost = new Mock<IServerApplicationHost>();
appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://media.example.com");
var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object);
var result = await helper.GetPlaybackInfo(new Movie(), null, Mock.Of<HttpRequest>(), liveStreamId: "live-1").ConfigureAwait(true);
Assert.Equal("https://media.example.com" + LiveStreamFilesPath, result.MediaSources[0].Path);
// The shared instance handed back by GetLiveStream must remain untouched; only the clone in the response may be rewritten.
Assert.Equal(LocalPath, sharedLiveSource.Path);
}
[Fact]
public async Task OpenMediaSource_RewritesReturnedLiveStreamPath()
{
var mediaSource = new MediaSourceInfo
{
Id = "abc",
Protocol = MediaProtocol.Http,
Path = "http://127.0.0.1:8096" + LiveStreamFilesPath,
LiveStreamId = "livestream-1"
};
var helper = CreateOpenMediaSourceHelper(mediaSource, "https://public.example.com");
var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
Assert.Equal("https://public.example.com" + LiveStreamFilesPath, response.MediaSource.Path);
}
[Fact]
public async Task OpenMediaSource_ExternalDockerBridgeBehindReverseProxy_UsesPublishedUrl()
{
const string LocalPath = "http://172.23.0.5:8096" + LiveStreamFilesPath;
// Represents the instance MediaSourceManager keeps for its own bookkeeping; the helper never sees it
// and must not be able to affect it.
var localSource = new MediaSourceInfo
{
Id = "abc",
Protocol = MediaProtocol.Http,
Path = LocalPath,
LiveStreamId = "livestream-1"
};
var mediaSourceManager = new Mock<IMediaSourceManager>();
mediaSourceManager
.Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
// Mirrors production: MediaSourceManager.OpenLiveStream hands back its own instance, so what the
// helper mutates must be a deserialized copy, never localSource itself.
var clone = JsonSerializer.Deserialize<MediaSourceInfo>(JsonSerializer.SerializeToUtf8Bytes(localSource))!;
return new LiveStreamResponse(clone);
});
var appHost = new Mock<IServerApplicationHost>();
appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://jellyfin.example.com");
var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object);
var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
Assert.Equal("https://jellyfin.example.com" + LiveStreamFilesPath, response.MediaSource.Path);
// The mock now actually derives its response from localSource, so this assertion is meaningful:
// rewriting the returned clone must never mutate the object localSource represents.
Assert.Equal(LocalPath, localSource.Path);
}
[Fact]
public async Task OpenMediaSource_ForeignHostWithLiveStreamFilesRoute_PathUnchanged()
{
// A plugin or remote source can expose a path that happens to match the /LiveTv/LiveStreamFiles/
// route shape without actually being hosted by this server. Only opened streams (which always
// carry a LiveStreamId) are eligible for rewriting.
const string ForeignPath = "https://other-server:8096" + LiveStreamFilesPath;
var mediaSource = new MediaSourceInfo
{
Id = "abc",
Protocol = MediaProtocol.Http,
Path = ForeignPath
};
var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com");
var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
Assert.Equal(ForeignPath, response.MediaSource.Path);
}
[Theory]
[InlineData(MediaProtocol.Http, "http://192.168.1.50:5004/live/channel1.ts")]
[InlineData(MediaProtocol.File, "/media/livetv/buffer/abc/stream.ts")]
[InlineData(MediaProtocol.Http, "http://172.19.0.3:8096/Videos/abc/stream.ts")]
[InlineData(MediaProtocol.Http, "http://172.19.0.3:8096/library/movie.strm")]
public async Task OpenMediaSource_NotAPublishableLiveStreamFilesPath_PathUnchanged(MediaProtocol protocol, string path)
{
var mediaSource = new MediaSourceInfo
{
Id = "abc",
Protocol = protocol,
Path = path
};
var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com");
var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
Assert.Equal(path, response.MediaSource.Path);
}
[Fact]
public async Task OpenMediaSource_BaseUrlConfigured_RewritesWithBaseUrlPrefix()
{
var mediaSource = new MediaSourceInfo
{
Id = "abc",
Protocol = MediaProtocol.Http,
Path = "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath,
LiveStreamId = "livestream-1"
};
var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com/jellyfin", "/jellyfin");
var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
Assert.Equal("https://media.example.com/jellyfin" + LiveStreamFilesPath, response.MediaSource.Path);
}
[Fact]
public async Task OpenMediaSource_BaseUrlSegmentMismatch_PathUnchanged()
{
const string LocalPath = "http://172.19.0.3:8096/jellyfin2" + LiveStreamFilesPath;
var mediaSource = new MediaSourceInfo
{
Id = "abc",
Protocol = MediaProtocol.Http,
Path = LocalPath
};
var helper = CreateOpenMediaSourceHelper(mediaSource, "https://media.example.com/jellyfin", "/jellyfin");
var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
Assert.Equal(LocalPath, response.MediaSource.Path);
}
[Fact]
public async Task OpenMediaSource_ExplicitPortOverrideWithBaseUrl_RewritesToOverrideHostAndPort()
{
// Mirrors NetworkManager.GetBindAddress resolving a "internal=myhost:8097" override: the smart API
// URL carries an explicit non-default port alongside the configured BaseUrl.
var mediaSource = new MediaSourceInfo
{
Id = "abc",
Protocol = MediaProtocol.Http,
Path = "http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath,
LiveStreamId = "livestream-1"
};
var helper = CreateOpenMediaSourceHelper(mediaSource, "http://myhost:8097/jellyfin", "/jellyfin");
var response = await helper.OpenMediaSource(new DefaultHttpContext(), new LiveStreamRequest()).ConfigureAwait(true);
Assert.Equal("http://myhost:8097/jellyfin" + LiveStreamFilesPath, response.MediaSource.Path);
}
[Fact]
public async Task GetPlaybackInfo_TwoRequestsForSharedLiveStream_ReceiveIndependentSmartApiBases()
{
const string LocalPath = "http://172.19.0.3:8096" + LiveStreamFilesPath;
// Both requests resolve the same live stream; the manager hands back its own shared instance each time.
var sharedLiveSource = new MediaSourceInfo
{
Id = "abc",
Protocol = MediaProtocol.Http,
Path = LocalPath,
LiveStreamId = "livestream-1"
};
var mediaSourceManager = new Mock<IMediaSourceManager>();
mediaSourceManager
.Setup(x => x.GetLiveStream(It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(sharedLiveSource);
var requestA = new DefaultHttpContext().Request;
var requestB = new DefaultHttpContext().Request;
var appHost = new Mock<IServerApplicationHost>();
appHost.Setup(x => x.GetSmartApiUrl(requestA)).Returns("https://a.example.com");
appHost.Setup(x => x.GetSmartApiUrl(requestB)).Returns("https://b.example.com");
var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object);
var resultA = await helper.GetPlaybackInfo(new Movie(), null, requestA, liveStreamId: "live-1").ConfigureAwait(true);
var resultB = await helper.GetPlaybackInfo(new Movie(), null, requestB, liveStreamId: "live-1").ConfigureAwait(true);
Assert.Equal("https://a.example.com" + LiveStreamFilesPath, resultA.MediaSources[0].Path);
Assert.Equal("https://b.example.com" + LiveStreamFilesPath, resultB.MediaSources[0].Path);
// Neither request's rewrite may leak into the other's response or into the shared instance.
Assert.NotEqual(resultA.MediaSources[0].Path, resultB.MediaSources[0].Path);
Assert.Equal(LocalPath, sharedLiveSource.Path);
}
[Fact]
public async Task GetPlaybackInfo_AutoOpenLiveStreamFlow_MergedOpenedSourceHasRewrittenPath()
{
// Reproduces MediaInfoController.GetPostedPlaybackInfo's AutoOpenLiveStream branch (~line 220-246):
// it picks the RequiresOpening source out of GetPlaybackInfo's result, calls OpenMediaSource, then
// merges by replacing result.MediaSources with the opened source. Building a full controller fixture
// is impractical (it pulls in many unrelated dependencies), so this test drives the same two helper
// calls the controller makes and asserts the merged source is the rewritten one.
var itemId = Guid.NewGuid();
var sourceId = itemId.ToString("N", CultureInfo.InvariantCulture);
// The pre-open placeholder source carries a different local path than the one OpenMediaSource
// eventually returns, so the final assertion can prove the merge picked up the freshly opened
// source rather than the stale placeholder.
var requiresOpeningSource = new MediaSourceInfo
{
Id = sourceId,
Protocol = MediaProtocol.Http,
Path = "http://172.19.0.3:8096/LiveTv/LiveStreamFiles/placeholder/stream.ts",
RequiresOpening = true,
LiveStreamId = string.Empty
};
var openedSource = new MediaSourceInfo
{
Id = sourceId,
Protocol = MediaProtocol.Http,
Path = "http://172.19.0.3:8096" + LiveStreamFilesPath,
LiveStreamId = "livestream-1"
};
var mediaSourceManager = new Mock<IMediaSourceManager>();
mediaSourceManager
.Setup(x => x.GetPlaybackMediaSources(It.IsAny<BaseItem>(), It.IsAny<User>(), true, true, It.IsAny<CancellationToken>()))
.ReturnsAsync(new[] { requiresOpeningSource });
mediaSourceManager
.Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(() =>
{
// MediaSourceManager.OpenLiveStream JSON-clones its internal MediaSourceInfo before returning
// it (see Emby.Server.Implementations/Library/MediaSourceManager.cs:693-706); mirror that so
// the in-place rewrite below can't be observed on openedSource itself.
var clone = JsonSerializer.Deserialize<MediaSourceInfo>(JsonSerializer.SerializeToUtf8Bytes(openedSource))!;
return new LiveStreamResponse(clone);
});
var appHost = new Mock<IServerApplicationHost>();
appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns("https://media.example.com");
var helper = CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object);
var info = await helper.GetPlaybackInfo(new Movie(), null, Mock.Of<HttpRequest>()).ConfigureAwait(true);
var mediaSource = info.MediaSources[0];
Assert.True(mediaSource.RequiresOpening);
var preOpenPath = mediaSource.Path;
var openStreamResult = await helper.OpenMediaSource(
new DefaultHttpContext(),
new LiveStreamRequest { OpenToken = mediaSource.OpenToken, ItemId = itemId }).ConfigureAwait(true);
// MediaInfoController.cs:245 - info.MediaSources = new[] { openStreamResult.MediaSource };
info.MediaSources = new[] { openStreamResult.MediaSource };
Assert.Equal("https://media.example.com" + LiveStreamFilesPath, info.MediaSources[0].Path);
Assert.NotEqual(preOpenPath, info.MediaSources[0].Path);
// The pristine OpenLiveStream response object must remain unrewritten; only the merged clone changed.
Assert.Equal("http://172.19.0.3:8096" + LiveStreamFilesPath, openedSource.Path);
}
[Theory]
[InlineData(
"https://media.example.com",
"http://172.19.0.3:8096" + LiveStreamFilesPath,
MediaProtocol.Http,
"",
"https://media.example.com" + LiveStreamFilesPath)]
[InlineData(
"https://media.example.com/",
"http://172.19.0.3:8096" + LiveStreamFilesPath + "?token=1",
MediaProtocol.Http,
"",
"https://media.example.com" + LiveStreamFilesPath + "?token=1")]
[InlineData(
"https://media.example.com",
"http://172.19.0.3:8096" + LiveStreamFilesPath + "#fragment",
MediaProtocol.Http,
"",
"https://media.example.com" + LiveStreamFilesPath)]
[InlineData(
"https://media.example.com",
"https://172.19.0.3:8920" + LiveStreamFilesPath,
MediaProtocol.Http,
"",
"https://media.example.com" + LiveStreamFilesPath)]
[InlineData(
"https://media.example.com",
"http://192.168.1.10:8096" + LiveStreamFilesPath,
MediaProtocol.Http,
"",
"https://media.example.com" + LiveStreamFilesPath)]
[InlineData(
"https://media.example.com:8920",
"http://172.19.0.3:8096" + LiveStreamFilesPath,
MediaProtocol.Http,
"",
"https://media.example.com:8920" + LiveStreamFilesPath)]
[InlineData(
"https://media.example.com",
"http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath,
MediaProtocol.Http,
"/jellyfin",
"https://media.example.com/jellyfin" + LiveStreamFilesPath)]
[InlineData(
"https://jellyfin",
"http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath,
MediaProtocol.Http,
"/jellyfin",
"https://jellyfin/jellyfin" + LiveStreamFilesPath)]
[InlineData(
"https://media.example.com/jellyfin",
"http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath,
MediaProtocol.Http,
"/jellyfin",
"https://media.example.com/jellyfin" + LiveStreamFilesPath)]
[InlineData(
"https://media.example.com/jellyfin/",
"http://172.19.0.3:8096/jellyfin" + LiveStreamFilesPath,
MediaProtocol.Http,
"/jellyfin",
"https://media.example.com/jellyfin" + LiveStreamFilesPath)]
[InlineData(
"https://media.example.com",
"http://172.19.0.3:8096/jellyfin2" + LiveStreamFilesPath,
MediaProtocol.Http,
"/jellyfin",
null)]
[InlineData(
"https://media.example.com",
"/media/livetv/buffer/abc/stream.ts",
MediaProtocol.File,
"",
null)]
[InlineData(
"https://media.example.com",
"not a uri",
MediaProtocol.Http,
"",
null)]
public void GetPublishedLiveStreamPath_VariousInputs_ReturnsExpected(string smartApiUrl, string localPath, MediaProtocol protocol, string baseUrl, string? expected)
{
var result = MediaInfoHelper.GetPublishedLiveStreamPath(smartApiUrl, localPath, protocol, baseUrl);
Assert.Equal(expected, result);
}
private static MediaInfoHelper CreateOpenMediaSourceHelper(MediaSourceInfo mediaSource, string smartApiUrl, string baseUrl = "")
{
var mediaSourceManager = new Mock<IMediaSourceManager>();
mediaSourceManager
.Setup(x => x.OpenLiveStream(It.IsAny<LiveStreamRequest>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new LiveStreamResponse(mediaSource));
var appHost = new Mock<IServerApplicationHost>();
appHost.Setup(x => x.GetSmartApiUrl(It.IsAny<HttpRequest>())).Returns(smartApiUrl);
return CreateHelper(mediaSourceManager: mediaSourceManager.Object, appHost: appHost.Object, baseUrl: baseUrl);
}
}
}

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