Compare commits

...

72 Commits

Author SHA1 Message Date
renovate[bot]
0d2f1b26b3 Update dependency dotnet-ef to v10.0.10 2026-07-14 20:11:43 +00:00
Bond-009
bc59e56ef5 Merge pull request #17266 from Shadowghost/fix-non-admin-additional-parts
Some checks are pending
CodeQL / Analyze (csharp) (push) Waiting to run
Format / format-check (push) Waiting to run
Tests / run-tests (macos-latest) (push) Waiting to run
Tests / run-tests (ubuntu-latest) (push) Waiting to run
Tests / run-tests (windows-latest) (push) Waiting to run
OpenAPI Publish / OpenAPI - Publish Artifact (push) Waiting to run
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Blocked by required conditions
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Blocked by required conditions
Project Automation / Project board (push) Waiting to run
Merge Conflict Labeler / main (push) Waiting to run
2026-07-14 15:36:38 +02:00
Bond-009
cfeaef6180 Merge pull request #17306 from theguymadmax/fix-strm-sub-protocol
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
2026-07-13 20:14:20 +02:00
Bond-009
ebab369eac Merge pull request #17273 from theguymadmax/ratigs-numerical-score 2026-07-13 20:13:29 +02:00
DrummingBird1
0584a102ee Translated using Weblate (Hebrew)
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/he/
2026-07-13 11:26:23 +00:00
theguymadmax
2aae53bc15 Apply review feedback 2026-07-12 11:54:56 -04:00
Bond-009
911044b415 Merge pull request #17287 from dkanada/audiobook-cover
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
2026-07-12 17:39:34 +02:00
Bond-009
b77def6883 Merge pull request #17268 from theguymadmax/fix-greece-ratings 2026-07-12 17:36:01 +02:00
Bond-009
5c48935fa8 Merge pull request #17220 from jellyfin/renovate/ci-deps 2026-07-12 17:33:36 +02:00
theguymadmax
e2d9d592bc Fix incorrect protocol used for subtitle charset detection 2026-07-11 17:33:06 -04:00
dkanada
305160d2e4 enable audiobook image saving to local folder 2026-07-11 11:00:46 +09:00
renovate[bot]
933f7eea1b Update CI dependencies 2026-07-10 07:47:01 +00:00
dkanada
ed06fd5139 support external images for audiobooks 2026-07-10 14:19:45 +09:00
theguymadmax
8bf1710e07 Check numeric rating value after splitting country code 2026-07-08 20:09:49 -04:00
theguymadmax
5e42941d3b Fix Greece parental ratings 2026-07-08 12:09:38 -04:00
Shadowghost
9a2fdb3573 Fix additional parts for non-admins 2026-07-08 13:43:30 +02:00
Shed Shedson
53aafcd38e Translated using Weblate (Icelandic)
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/is/
2026-07-07 18:12:36 +00:00
Shed Shedson
d73e65172a Translated using Weblate (Icelandic)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/is/
2026-07-07 16:33:35 +00:00
yuuta0331
974038e9b0 Translated using Weblate (Japanese)
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/ja/
2026-07-07 02:20:57 +00:00
Lofuuzi
e31f168e9b Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/zh_Hant_HK/
2026-07-06 08:24:25 +00:00
Ulrik
7f5537ca47 Translated using Weblate (Danish)
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/da/
2026-07-06 05:12:54 +00:00
Cody Robibero
a03072b617 Merge pull request #16668 from johnpc/fix/people-images-not-displayed
Some checks failed
Stale PR Check / Check PRs with merge conflicts (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Stale Issue Labeler / Check for stale issues (push) Has been cancelled
Fix actor images not displayed until clicked
2026-07-05 16:25:38 -04:00
Cody Robibero
31d05dbdd6 Merge pull request #17228 from Shadowghost/fix-logout-concurrency
Don't throw on logout if session does not exist
2026-07-05 16:22:45 -04:00
Cody Robibero
d4faf7bd72 Merge pull request #17238 from iderex/fix/dateparse-format-provider
Use InvariantCulture when parsing machine-generated dates
2026-07-05 16:21:56 -04:00
Cody Robibero
6e728b009f Merge pull request #17044 from Shadowghost/version-model-and-handling
Fixes for multi version handling
2026-07-05 16:21:02 -04:00
Cody Robibero
efd3814a7e Merge pull request #17239 from theguymadmax/ratings-separator
Fix parental rating lookup for multi-rating entries
2026-07-05 16:20:45 -04:00
Bond-009
b391cd5e9e Merge pull request #17231 from theguymadmax/fix-paths-not-being-deleted
Fix ghost entries when deleting library paths
2026-07-05 16:19:46 +02:00
Bond-009
8433773fad Allow changing capitalization of usernames (#17229)
Fixes #17195
Adds a regression test
2026-07-05 15:59:14 +02:00
Lofuuzi
381bf18161 Translated using Weblate (Chinese (Traditional Han script, Hong Kong))
Some checks failed
CodeQL / Analyze (csharp) (push) Has been cancelled
Format / format-check (push) Has been cancelled
Tests / run-tests (macos-latest) (push) Has been cancelled
Tests / run-tests (ubuntu-latest) (push) Has been cancelled
Tests / run-tests (windows-latest) (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Artifact (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Unstable Spec (push) Has been cancelled
OpenAPI Publish / OpenAPI - Publish Stable Spec (push) Has been cancelled
Project Automation / Project board (push) Has been cancelled
Merge Conflict Labeler / main (push) Has been cancelled
Translation: Jellyfin/Jellyfin
Translate-URL: https://translate.jellyfin.org/projects/jellyfin/jellyfin-core/zh_Hant_HK/
2026-07-05 08:08:46 +00:00
Nils Lehnen
f8ffccae7f Use InvariantCulture when parsing machine-generated dates
DateTime.TryParse without an IFormatProvider falls back to the current
thread culture, so the same string can parse differently (or fail)
depending on the server's locale. None of these call sites deal with
user-entered text - they parse dates that come from filenames, an
HTTP header, ffprobe metadata and values the app itself wrote to the
auth database - so InvariantCulture is the correct provider everywhere
here.

Fixes the S6580 / CA1305 warnings on these call sites.

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

Fixes #17009

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

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

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

Fixes jellyfin#8103
2026-06-28 21:44:46 -04:00
Marc Brooks
70b4589382 Fix Book collections scanning all items
Added static method GetBaseItemKindsForCollectionType in ItemsController (moved from ContentFolderImageProvider to be shared)

Added AudioBook to GetRepresentativeItemTypes for CollectionType.books for consistency

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

Move GetBaseItemKindsForCollectionType to DtoExtensions

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

View File

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

View File

@@ -32,13 +32,13 @@ jobs:
dotnet-version: '10.0.x'
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
uses: github/codeql-action/autobuild@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/autobuild@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@99df26d4f13ea111d4ec1a7dddef6063f76b97e9 # v4.37.0

View File

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

View File

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

View File

@@ -28,10 +28,10 @@
<PackageVersion Include="MetaBrainz.MusicBrainz" Version="8.0.1" />
<PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="10.0.9" />
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="4.14.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.3.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Common" Version="5.6.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.CSharp" Version="5.6.0" />
<PackageVersion Include="Microsoft.CodeAnalysis.Analyzers" Version="5.6.0" />
<PackageVersion Include="Microsoft.Data.Sqlite" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -170,7 +170,7 @@ namespace Jellyfin.Server.Implementations.Users
{
ThrowIfInvalidUsername(newName);
if (oldName.Equals(newName, StringComparison.OrdinalIgnoreCase))
if (oldName.Equals(newName, StringComparison.Ordinal))
{
throw new ArgumentException("The new and old names must be different.");
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -167,7 +167,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
{
if (fileInfo.Protocol == MediaProtocol.Http)
{
var result = await DetectCharset(fileInfo.Path, fileInfo.Protocol, cancellationToken).ConfigureAwait(false);
var result = await DetectCharset(fileInfo.Path, cancellationToken).ConfigureAwait(false);
var detected = result.Detected;
if (detected is not null)
@@ -1104,7 +1104,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
}
var result = await DetectCharset(path, mediaSource.Protocol, cancellationToken).ConfigureAwait(false);
var result = await DetectCharset(path, cancellationToken).ConfigureAwait(false);
var charset = result.Detected?.EncodingName ?? string.Empty;
// UTF16 is automatically converted to UTF8 by FFmpeg, do not specify a character encoding
@@ -1120,8 +1120,9 @@ namespace MediaBrowser.MediaEncoding.Subtitles
return charset;
}
private async Task<DetectionResult> DetectCharset(string path, MediaProtocol protocol, CancellationToken cancellationToken)
private async Task<DetectionResult> DetectCharset(string path, CancellationToken cancellationToken)
{
var protocol = _mediaSourceManager.GetPathProtocol(path);
switch (protocol)
{
case MediaProtocol.Http:
@@ -1141,7 +1142,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles
}
default:
throw new ArgumentOutOfRangeException(nameof(protocol), protocol, "Unsupported protocol");
throw new NotSupportedException($"Unsupported protocol: {protocol}");
}
}

View File

@@ -55,6 +55,11 @@ namespace MediaBrowser.Model.Dto
public long? RunTimeTicks { get; set; }
/// <summary>
/// Gets or sets the playback position for this specific source.
/// </summary>
public long? PlaybackPositionTicks { get; set; }
public bool ReadAtNativeFramerate { get; set; }
public bool IgnoreDts { get; set; }

View File

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

View File

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

View File

@@ -78,11 +78,73 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
{
await base.AfterMetadataRefresh(item, refreshOptions, cancellationToken).ConfigureAwait(false);
// Note that this only updates the children's SeriesPresentationUniqueKey and SeasonId, not the ParentIndexNumber
if (LibraryManager.GetLibraryOptions(item).EnableAutomaticSeriesGrouping)
{
await UpdateSeriesChildrenInfoAsync(item, cancellationToken).ConfigureAwait(false);
}
RemoveObsoleteEpisodes(item);
RemoveObsoleteSeasons(item);
await CreateSeasonsAsync(item, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Reconciles seasons and episodes with the series' finalized state.
/// </summary>
/// <remarks>
/// The series' presentation unique key can change during a refresh once provider ids become
/// available - notably with <c>EnableAutomaticSeriesGrouping</c>, where the key is derived from
/// the provider id and the owning libraries instead of the (immutable) item id. Seasons and
/// episodes cache this value in <see cref="IHasSeries.SeriesPresentationUniqueKey"/> and are
/// matched to (and displayed under) the series by it, so any child left with a stale key - or an
/// episode not yet linked to a freshly created season - stays hidden until a later scan. Syncing
/// them against the series here lets everything appear within a single scan.
/// </remarks>
/// <param name="series">The series.</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The async task.</returns>
private async Task UpdateSeriesChildrenInfoAsync(Series series, CancellationToken cancellationToken)
{
// Reload children so episode numbers / seasons persisted earlier in the refresh are seen.
series.Children = null;
var seriesKey = series.GetPresentationUniqueKey();
var children = series.GetRecursiveChildren(i => i is Season || i is Episode);
var seasons = children.OfType<Season>().ToList();
foreach (var child in children)
{
var updateType = ItemUpdateType.None;
if (child is IHasSeries hasSeries
&& !string.Equals(hasSeries.SeriesPresentationUniqueKey, seriesKey, StringComparison.Ordinal))
{
hasSeries.SeriesPresentationUniqueKey = seriesKey;
updateType |= ItemUpdateType.MetadataImport;
}
if (child is Episode episode)
{
var seasonId = episode.FindSeasonId();
if (seasonId.IsEmpty() && episode.ParentIndexNumber.HasValue)
{
seasonId = seasons.Find(s => s.IndexNumber == episode.ParentIndexNumber)?.Id ?? Guid.Empty;
}
if (!seasonId.IsEmpty() && !episode.SeasonId.Equals(seasonId))
{
episode.SeasonId = seasonId;
updateType |= ItemUpdateType.MetadataImport;
}
}
if (updateType > ItemUpdateType.None)
{
await child.UpdateToRepositoryAsync(updateType, cancellationToken).ConfigureAwait(false);
}
}
}
/// <inheritdoc />
protected override void MergeData(MetadataResult<Series> source, MetadataResult<Series> target, MetadataField[] lockedFields, bool replaceData, bool mergeMetadataSettings)
{
@@ -235,6 +297,28 @@ public class SeriesMetadataService : MetadataService<Series, SeriesInfo>
private async Task CreateSeasonsAsync(Series series, CancellationToken cancellationToken)
{
var seriesChildren = series.GetRecursiveChildren(i => i is Episode || i is Season);
// CreateSeasonsAsync can run before the episodes themselves have been refreshed during an
// initial scan, so their ParentIndexNumber may still be unset. Resolve the season number
// from the path first to avoid creating a premature "Season Unknown" instead of the real
// season for episodes that live directly in a flat series folder.
foreach (var episode in seriesChildren.OfType<Episode>())
{
if (episode.ParentIndexNumber.HasValue)
{
continue;
}
try
{
LibraryManager.FillMissingEpisodeNumbersFromPath(episode, false);
}
catch (Exception ex)
{
Logger.LogError(ex, "Error resolving season number from path for {Path}", episode.Path);
}
}
var seasons = seriesChildren.OfType<Season>().ToList();
var episodes = seriesChildren.OfType<Episode>().ToList();

View File

@@ -1,6 +1,15 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.MediaInfo;
using Moq;
using Xunit;
@@ -46,4 +55,284 @@ public class BaseItemTests
Assert.Equal(name, video.GetMediaSourceName(video));
Assert.Equal(altName, video.GetMediaSourceName(videoAlt));
}
[Theory]
// Episode versions share a season folder; the common prefix (not the folder name) yields the label.
// Both files carry a suffix (no bare base name), so the shared "- " must be stripped too.
[InlineData(
"Spider-Noir - S01E02 - Wo ist Flint - Greyscale",
"Spider-Noir - S01E02 - Wo ist Flint - Colorized",
"Greyscale",
"Colorized")]
// One version is the bare base name; the other is suffixed.
[InlineData(
"Spider-Noir - S01E02 - Wo ist Flint",
"Spider-Noir - S01E02 - Wo ist Flint - Greyscale",
"Spider-Noir - S01E02 - Wo ist Flint",
"Greyscale")]
// Suffixes share a leading word ("Grey"); the prefix must retreat to the separator, not split it.
[InlineData(
"Demo - S01E01 - Greyscale",
"Demo - S01E01 - Greyish",
"Greyscale",
"Greyish")]
// Underscore separator.
[InlineData("Movie (2020)_4K", "Movie (2020)_1080p", "4K", "1080p")]
// Dot separator.
[InlineData("Movie (2020).UHD", "Movie (2020).1080p", "UHD", "1080p")]
// Resolution variants that share leading digits must retreat to the separator, not yield "p"/"i".
[InlineData("Movie - 1080p", "Movie - 1080i", "1080p", "1080i")]
// A token shared by the descriptors but separated only by spaces (the resolution) must stay in the
// label: retreat to the '-' delimiter, not the interior space, so the resolution is kept.
[InlineData(
"movie (2020) - 2160p Extended",
"movie (2020) - 2160p Original",
"2160p Extended",
"2160p Original")]
// Bracketed version labels: the opening bracket is kept in the label.
[InlineData(
"Blade Runner (1982) [Final Cut] [1080p HEVC AAC]",
"Blade Runner (1982) [EE by ADM] [480p HEVC AAC]",
"[Final Cut] [1080p HEVC AAC]",
"[EE by ADM] [480p HEVC AAC]")]
public void GetMediaSourceName_CommonPrefix_Valid(string primaryName, string altName, string expectedPrimary, string expectedAlt)
{
var primaryPath = "/Shows/Demo/Season 01/" + primaryName + ".mkv";
var altPath = "/Shows/Demo/Season 01/" + altName + ".mkv";
var commonPrefix = BaseItem.GetCommonVersionPrefix([primaryName, altName]);
var video = new Video()
{
Path = primaryPath
};
var videoAlt = new Video()
{
Path = altPath,
};
var mediaSourceManager = new Mock<IMediaSourceManager>();
mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>()))
.Returns((string x) => MediaProtocol.File);
var libraryManager = new Mock<ILibraryManager>();
// No local alternate versions: these are linked (separate items), so the folder fallback is unavailable.
libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>()))
.Returns(Array.Empty<Guid>());
BaseItem.MediaSourceManager = mediaSourceManager.Object;
BaseItem.LibraryManager = libraryManager.Object;
Assert.Equal(expectedPrimary, video.GetMediaSourceName(video, commonPrefix));
Assert.Equal(expectedAlt, videoAlt.GetMediaSourceName(videoAlt, commonPrefix));
}
[Fact]
public void GetAlternateVersion_ReturnsMatchingLocalVersion()
{
var (primary, alt1, alt2) = SetupVersionGroup();
Assert.Same(alt1, primary.GetAlternateVersion(alt1.Id));
Assert.Same(alt2, primary.GetAlternateVersion(alt2.Id));
Assert.Same(primary, primary.GetAlternateVersion(primary.Id));
Assert.Null(primary.GetAlternateVersion(Guid.NewGuid()));
}
[Fact]
public void GetAllVersions_FromAnyVersion_ReturnsEveryVersionOnce()
{
var (primary, alt1, alt2) = SetupVersionGroup();
foreach (var source in new[] { primary, alt1, alt2 })
{
var versions = source.GetAllVersions();
Assert.Equal(3, versions.Count);
Assert.Contains(versions, v => v.Id.Equals(primary.Id));
Assert.Contains(versions, v => v.Id.Equals(alt1.Id));
Assert.Contains(versions, v => v.Id.Equals(alt2.Id));
}
}
[Fact]
public void PropagatePlayedState_MarksAlternateVersions_AndResetsPositionByDefault()
{
var (primary, alt1, alt2) = SetupVersionGroup();
var saved = CaptureSaves();
var user = new User("test", "default", "default");
primary.PropagatePlayedState(user, true);
// Both alternate versions are marked played, the primary (self) is not, and the position is
// reset so a watched version does not linger in "Continue Watching".
Assert.Equal(2, saved.Count);
Assert.DoesNotContain(saved, e => e.ItemId.Equals(primary.Id));
Assert.Contains(saved, e => e.ItemId.Equals(alt1.Id));
Assert.Contains(saved, e => e.ItemId.Equals(alt2.Id));
Assert.All(saved, e =>
{
Assert.True(e.Dto.Played.GetValueOrDefault());
Assert.Equal(0, e.Dto.PlaybackPositionTicks);
});
}
[Fact]
public void PropagatePlayedState_WithoutReset_LeavesPositionUntouched()
{
var (primary, _, _) = SetupVersionGroup();
var saved = CaptureSaves();
primary.PropagatePlayedState(new User("test", "default", "default"), true, resetPosition: false);
Assert.Equal(2, saved.Count);
Assert.All(saved, e =>
{
Assert.True(e.Dto.Played.GetValueOrDefault());
Assert.Null(e.Dto.PlaybackPositionTicks);
});
}
[Fact]
public void PropagatePlayedState_Unwatched_ClearsAllWatchedStateOnVersions()
{
var (primary, alt1, alt2) = SetupVersionGroup();
// Each alternate starts out watched, with a play count, resume point and last-played date.
var existing = new Dictionary<Guid, UserItemData>
{
[alt1.Id] = new UserItemData { Key = "alt1", Played = true, PlayCount = 3, PlaybackPositionTicks = 1000, LastPlayedDate = new DateTime(2020, 1, 1, 0, 0, 0, DateTimeKind.Utc) },
[alt2.Id] = new UserItemData { Key = "alt2", Played = true, PlayCount = 1, PlaybackPositionTicks = 500, LastPlayedDate = new DateTime(2021, 2, 2, 0, 0, 0, DateTimeKind.Utc) },
};
var saved = new List<UserItemData>();
var userDataManager = new Mock<IUserDataManager>();
userDataManager.Setup(x => x.GetUserData(It.IsAny<User>(), It.IsAny<BaseItem>()))
.Returns((User _, BaseItem item) => existing.GetValueOrDefault(item.Id));
userDataManager
.Setup(x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UserItemData>(), It.IsAny<UserDataSaveReason>(), It.IsAny<CancellationToken>()))
.Callback<User, BaseItem, UserItemData, UserDataSaveReason, CancellationToken>((_, _, data, _, _) => saved.Add(data));
BaseItem.UserDataManager = userDataManager.Object;
primary.PropagatePlayedState(new User("test", "default", "default"), false);
// Every alternate is fully reset to an unwatched state, mirroring MarkUnplayed: the played flag,
// play count, resume point and last-played date are all cleared so no watched state lingers.
Assert.Equal(2, saved.Count);
Assert.All(saved, d =>
{
Assert.False(d.Played);
Assert.Equal(0, d.PlayCount);
Assert.Equal(0, d.PlaybackPositionTicks);
Assert.Null(d.LastPlayedDate);
});
}
private static List<(Guid ItemId, UpdateUserItemDataDto Dto)> CaptureSaves()
{
var saved = new List<(Guid ItemId, UpdateUserItemDataDto Dto)>();
var userDataManager = new Mock<IUserDataManager>();
userDataManager
.Setup(x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UpdateUserItemDataDto>(), It.IsAny<UserDataSaveReason>()))
.Callback<User, BaseItem, UpdateUserItemDataDto, UserDataSaveReason>((_, item, dto, _) => saved.Add((item.Id, dto)));
BaseItem.UserDataManager = userDataManager.Object;
return saved;
}
[Fact]
public void PropagatePlayedState_SingleVersion_DoesNothing()
{
var solo = new Video { Id = Guid.NewGuid(), Path = "/Movies/Solo/Solo.mkv" };
var mediaSourceManager = new Mock<IMediaSourceManager>();
mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
var libraryManager = new Mock<ILibraryManager>();
libraryManager.Setup(x => x.GetLocalAlternateVersionIds(It.IsAny<Video>())).Returns(Array.Empty<Guid>());
libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
BaseItem.MediaSourceManager = mediaSourceManager.Object;
BaseItem.LibraryManager = libraryManager.Object;
var userDataManager = new Mock<IUserDataManager>();
BaseItem.UserDataManager = userDataManager.Object;
solo.PropagatePlayedState(new User("test", "default", "default"), true);
userDataManager.Verify(
x => x.SaveUserData(It.IsAny<User>(), It.IsAny<BaseItem>(), It.IsAny<UpdateUserItemDataDto>(), It.IsAny<UserDataSaveReason>()),
Times.Never);
}
private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup()
{
var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" };
var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id };
var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id };
// 2160p primary, 1080p alternates: width is only the ordering tiebreaker, set so it would place
// the primary first — letting the tests confirm the queried version's own source still wins.
var widths = new Dictionary<Guid, int> { [primary.Id] = 3840, [alt1.Id] = 1920, [alt2.Id] = 1920 };
var mediaSourceManager = new Mock<IMediaSourceManager>();
mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>()))
.Returns((Guid id) => new List<MediaStream> { new MediaStream { Type = MediaStreamType.Video, Width = widths.GetValueOrDefault(id) } });
mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>());
var segmentManager = new Mock<IMediaSegmentManager>();
segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false);
BaseItem.MediaSegmentManager = segmentManager.Object;
var libraryManager = new Mock<ILibraryManager>();
libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id });
libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>());
libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>());
libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1);
libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2);
libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary);
var recordingsManager = new Mock<IRecordingsManager>();
recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null);
Video.RecordingsManager = recordingsManager.Object;
BaseItem.MediaSourceManager = mediaSourceManager.Object;
BaseItem.LibraryManager = libraryManager.Object;
return (primary, alt1, alt2);
}
[Fact]
public void GetMediaSources_DefaultsToTheQueriedVersionsOwnSource()
{
var (primary, alt1, _) = SetupVersionGroup();
// Resuming the 1080p alternate must default to the 1080p source, not the higher-resolution
// 2160p primary that the width ordering would otherwise place first.
Assert.Equal(alt1.Id.ToString("N"), alt1.GetMediaSources(false)[0].Id);
// Opening the primary still defaults to the primary's own (here highest-resolution) source.
Assert.Equal(primary.Id.ToString("N"), primary.GetMediaSources(false)[0].Id);
}
[Fact]
public void GetAllItemsForMediaSources_FromAnyVersion_HasNoDuplicates()
{
var (primary, alt1, alt2) = SetupVersionGroup();
var method = typeof(Video).GetMethod("GetAllItemsForMediaSources", BindingFlags.Instance | BindingFlags.NonPublic);
Assert.NotNull(method);
// Each version must surface exactly once, regardless of which member the list is built from.
// Building from an alternate previously re-added that alternate as a "local alternate" of the
// primary, producing a duplicate entry in the version dropdown.
foreach (var source in new[] { primary, alt1, alt2 })
{
var items = (IEnumerable<(BaseItem Item, MediaSourceType MediaSourceType)>)method!.Invoke(source, null)!;
var ids = items.Select(i => i.Item.Id).ToList();
Assert.Equal(3, ids.Count);
Assert.Equal(ids.Count, ids.Distinct().Count());
Assert.Contains(primary.Id, ids);
Assert.Contains(alt1.Id, ids);
Assert.Contains(alt2.Id, ids);
}
}
}

View File

@@ -0,0 +1,89 @@
using System;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Dto;
using Xunit;
namespace Jellyfin.Controller.Tests.Library;
public class VersionResumeDataTests
{
[Fact]
public void ApplyTo_CompletedOtherVersion_PropagatesCompletionAndClearsStaleResume()
{
var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
var resume = new VersionResumeData(
Guid.NewGuid(),
new UserItemData { Key = "version", PlaybackPositionTicks = 0, Played = true, LastPlayedDate = lastPlayed });
var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 };
resume.ApplyTo(dto);
// Completion state propagates to the primary...
Assert.True(dto.Played);
Assert.Equal(lastPlayed, dto.LastPlayedDate);
// ...and because the movie was finished on a different version, the primary's own stale resume bar is cleared.
Assert.Equal(0, dto.PlaybackPositionTicks);
Assert.Null(dto.PlayedPercentage);
}
[Fact]
public void ApplyTo_PrimaryOwnProgress_KeepsResumePosition()
{
var primaryId = Guid.NewGuid();
var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
// The winning version is the primary itself (e.g. rewatching): its resume bar must survive.
var resume = new VersionResumeData(
primaryId,
new UserItemData { Key = "primary", PlaybackPositionTicks = 5, Played = true, LastPlayedDate = lastPlayed });
var dto = new UserItemDataDto { ItemId = primaryId, Key = "primary", PlaybackPositionTicks = 5, Played = true, PlayedPercentage = 20 };
resume.ApplyTo(dto);
Assert.True(dto.Played);
Assert.Equal(5, dto.PlaybackPositionTicks);
Assert.Equal(20, dto.PlayedPercentage);
}
[Fact]
public void ApplyTo_InProgressOtherVersion_KeepsPrimaryResumePosition()
{
var lastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
// A different version that is in-progress (not finished) must not clear the primary's position.
var resume = new VersionResumeData(
Guid.NewGuid(),
new UserItemData { Key = "version", PlaybackPositionTicks = 25, Played = false, LastPlayedDate = lastPlayed });
var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", PlaybackPositionTicks = 1, Played = false, PlayedPercentage = 50 };
resume.ApplyTo(dto);
Assert.False(dto.Played);
Assert.Equal(1, dto.PlaybackPositionTicks);
Assert.Equal(50, dto.PlayedPercentage);
}
[Fact]
public void ApplyTo_DoesNotUnsetExistingPlayedOrRegressLastPlayed()
{
var primaryLastPlayed = new DateTime(2026, 1, 5, 0, 0, 0, DateTimeKind.Utc);
var versionLastPlayed = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
var resume = new VersionResumeData(
Guid.NewGuid(),
new UserItemData { Key = "version", Played = false, LastPlayedDate = versionLastPlayed });
var dto = new UserItemDataDto { ItemId = Guid.NewGuid(), Key = "primary", Played = true, LastPlayedDate = primaryLastPlayed };
resume.ApplyTo(dto);
// A not-yet-completed version must not clear the primary's own completion, and the more recent
// LastPlayedDate is kept.
Assert.True(dto.Played);
Assert.Equal(primaryLastPlayed, dto.LastPlayedDate);
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Text.Json;
using Jellyfin.Data.Enums;
using Jellyfin.Extensions.Json;
@@ -56,6 +57,43 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
public void IsNearSquarePixelSar_DetectsCorrectly(string? sar, bool expected)
=> Assert.Equal(expected, ProbeResultNormalizer.IsNearSquarePixelSar(sar));
[Theory]
// Lossy codecs, mono/stereo and multichannel.
[InlineData("aac", null, 2, 192000)]
[InlineData("mp3", null, 2, 192000)]
[InlineData("mp2", null, 2, 192000)]
[InlineData("aac", null, 6, 320000)]
[InlineData("ac3", null, 2, 192000)]
[InlineData("eac3", null, 6, 640000)]
[InlineData("opus", null, 2, 128000)]
[InlineData("vorbis", null, 6, 320000)]
[InlineData("wmav2", null, 2, 192000)]
// DTS: the lossy core (any non-MA profile, or none) is flat and caps at 5.1...
[InlineData("dts", null, 2, 768000)]
[InlineData("dts", "DTS", 6, 1509000)]
[InlineData("dts", "DTS-HD HRA", 8, 1509000)]
// ...while lossless DTS-HD MA scales per channel like other lossless codecs.
[InlineData("dts", "DTS-HD MA", 6, 4200000)]
[InlineData("dts", "DTS-HD MA + DTS:X", 8, 5600000)]
// Lossless codecs scale per channel.
[InlineData("flac", null, 2, 960000)]
[InlineData("flac", null, 6, 2880000)]
[InlineData("flac", null, 8, 3840000)]
[InlineData("alac", null, 6, 2880000)]
[InlineData("truehd", null, 2, 1400000)]
[InlineData("truehd", null, 6, 4200000)]
[InlineData("truehd", "Dolby TrueHD + Dolby Atmos", 8, 5600000)]
// 3-4 channel audio must use the multichannel estimate, not return null.
[InlineData("aac", null, 3, 320000)]
[InlineData("ac3", null, 4, 640000)]
// Codec matching is case-insensitive.
[InlineData("AAC", null, 2, 192000)]
// Unknown codec or unknown channel count cannot be estimated.
[InlineData("pcm_s16le", null, 2, null)]
[InlineData("aac", null, null, null)]
public void GetEstimatedAudioBitrate_ReturnsExpected(string codec, string? profile, int? channels, int? expected)
=> Assert.Equal(expected, ProbeResultNormalizer.GetEstimatedAudioBitrate(codec, profile, channels));
[Fact]
public void GetMediaInfo_MetaData_Success()
{
@@ -71,7 +109,10 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
Assert.Equal("4:3", res.VideoStream.AspectRatio);
Assert.Equal(25f, res.VideoStream.AverageFrameRate);
Assert.Equal(8, res.VideoStream.BitDepth);
Assert.Equal(69432, res.VideoStream.BitRate);
// ffprobe reports no per-stream video bitrate here. The container bitrate must not be
// misreported as the video bitrate, and the other streams' bitrates exceed the container
// bitrate in this sample, so no sensible video bitrate can be inferred (see #16248).
Assert.Null(res.VideoStream.BitRate);
Assert.Equal("h264", res.VideoStream.Codec);
Assert.Equal("1/50", res.VideoStream.CodecTimeBase);
Assert.Equal(240, res.VideoStream.Height);
@@ -321,6 +362,73 @@ namespace Jellyfin.MediaEncoding.Tests.Probing
Assert.True(res.VideoStream.IsDefault);
}
[Fact]
public void GetMediaInfo_MissingVideoBitrate_EstimatedFromContainer()
{
// ffprobe did not report a per-stream video bitrate. The video bitrate must be estimated
// as the container bitrate minus the other (audio) stream bitrates, not reported as the
// whole container bitrate (see #16248).
var bytes = File.ReadAllBytes("Test Data/Probing/video_missing_video_bitrate.json");
var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_missing_video_bitrate.mp4", MediaProtocol.File);
Assert.Equal(2, res.MediaStreams.Count);
Assert.NotNull(res.VideoStream);
Assert.Equal(MediaStreamType.Video, res.VideoStream.Type);
var audioStream = res.MediaStreams.First(i => i.Type == MediaStreamType.Audio);
Assert.Equal(128000, audioStream.BitRate);
// Container bitrate (5128000) minus the audio bitrate (128000).
Assert.Equal(5000000, res.VideoStream.BitRate);
// The container bitrate itself must remain the overall container bitrate.
Assert.Equal(5128000, res.Bitrate);
}
[Fact]
public void GetMediaInfo_NanosecondDurationTag_BitrateComputedFromBytes()
{
// The stream carries NUMBER_OF_BYTES and a nanosecond-precision DURATION tag but no
// bitrate. TimeSpan only supports 7 fractional digits, so the 9-digit DURATION must be
// trimmed for the duration to parse and the bitrate to be computed (bytes * 8 / seconds).
var bytes = File.ReadAllBytes("Test Data/Probing/video_nanosecond_duration_bitrate.json");
var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_nanosecond_duration_bitrate.mkv", MediaProtocol.File);
Assert.NotNull(res.VideoStream);
// 10000000 bytes * 8 / 100 seconds.
Assert.Equal(800000, res.VideoStream.BitRate);
}
[Fact]
public void GetMediaInfo_MissingVideoBitrate_UnknownAudioBitrate_NotEstimated()
{
// ffprobe reported no per-stream video bitrate and the audio bitrate cannot be estimated
// (the audio stream has no channel count, so GetEstimatedAudioBitrate returns null). The
// video bitrate must be left unset rather than wrongly absorbing the unaccounted audio
// bitrate (see #16248).
var bytes = File.ReadAllBytes("Test Data/Probing/video_missing_video_bitrate_unknown_audio.json");
var internalMediaInfoResult = JsonSerializer.Deserialize<InternalMediaInfoResult>(bytes, _jsonOptions);
MediaInfo res = _probeResultNormalizer.GetMediaInfo(internalMediaInfoResult, VideoType.VideoFile, false, "Test Data/Probing/video_missing_video_bitrate_unknown_audio.mp4", MediaProtocol.File);
Assert.Equal(2, res.MediaStreams.Count);
Assert.NotNull(res.VideoStream);
Assert.Null(res.VideoStream.BitRate);
var audioStream = res.MediaStreams.First(i => i.Type == MediaStreamType.Audio);
Assert.Null(audioStream.BitRate);
// The overall container bitrate is still reported.
Assert.Equal(5128000, res.Bitrate);
}
[Fact]
public void GetMediaInfo_VideoWithSingleFrameMjpeg_Success()
{

View File

@@ -0,0 +1,113 @@
{
"streams": [
{
"index": 0,
"codec_name": "h264",
"codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
"profile": "High",
"codec_type": "video",
"codec_time_base": "1/48",
"codec_tag_string": "avc1",
"codec_tag": "0x31637661",
"width": 1920,
"height": 1080,
"coded_width": 1920,
"coded_height": 1080,
"closed_captions": 0,
"has_b_frames": 2,
"pix_fmt": "yuv420p",
"level": 40,
"chroma_location": "left",
"refs": 1,
"is_avc": "true",
"nal_length_size": "4",
"r_frame_rate": "24/1",
"avg_frame_rate": "24/1",
"time_base": "1/12288",
"start_pts": 0,
"start_time": "0.000000",
"duration_ts": 3686400,
"duration": "300.000000",
"bits_per_raw_sample": "8",
"nb_frames": "7200",
"disposition": {
"default": 1,
"dub": 0,
"original": 0,
"comment": 0,
"lyrics": 0,
"karaoke": 0,
"forced": 0,
"hearing_impaired": 0,
"visual_impaired": 0,
"clean_effects": 0,
"attached_pic": 0,
"timed_thumbnails": 0
},
"tags": {
"language": "und",
"handler_name": "VideoHandler"
}
},
{
"index": 1,
"codec_name": "aac",
"codec_long_name": "AAC (Advanced Audio Coding)",
"profile": "LC",
"codec_type": "audio",
"codec_time_base": "1/48000",
"codec_tag_string": "mp4a",
"codec_tag": "0x6134706d",
"sample_fmt": "fltp",
"sample_rate": "48000",
"channels": 2,
"channel_layout": "stereo",
"bits_per_sample": 0,
"r_frame_rate": "0/0",
"avg_frame_rate": "0/0",
"time_base": "1/48000",
"start_pts": 0,
"start_time": "0.000000",
"duration_ts": 14400000,
"duration": "300.000000",
"bit_rate": "128000",
"nb_frames": "14063",
"disposition": {
"default": 1,
"dub": 0,
"original": 0,
"comment": 0,
"lyrics": 0,
"karaoke": 0,
"forced": 0,
"hearing_impaired": 0,
"visual_impaired": 0,
"clean_effects": 0,
"attached_pic": 0,
"timed_thumbnails": 0
},
"tags": {
"language": "eng",
"handler_name": "SoundHandler"
}
}
],
"format": {
"filename": "test.1080p.mp4",
"nb_streams": 2,
"nb_programs": 0,
"format_name": "mov,mp4,m4a,3gp,3g2,mj2",
"format_long_name": "QuickTime / MOV",
"start_time": "0.000000",
"duration": "300.000000",
"size": "192000000",
"bit_rate": "5128000",
"probe_score": 100,
"tags": {
"major_brand": "isom",
"minor_version": "512",
"compatible_brands": "isomiso2avc1mp41",
"encoder": "Lavf58.20.100"
}
}
}

View File

@@ -0,0 +1,110 @@
{
"streams": [
{
"index": 0,
"codec_name": "h264",
"codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
"profile": "High",
"codec_type": "video",
"codec_time_base": "1/48",
"codec_tag_string": "avc1",
"codec_tag": "0x31637661",
"width": 1920,
"height": 1080,
"coded_width": 1920,
"coded_height": 1080,
"closed_captions": 0,
"has_b_frames": 2,
"pix_fmt": "yuv420p",
"level": 40,
"chroma_location": "left",
"refs": 1,
"is_avc": "true",
"nal_length_size": "4",
"r_frame_rate": "24/1",
"avg_frame_rate": "24/1",
"time_base": "1/12288",
"start_pts": 0,
"start_time": "0.000000",
"duration_ts": 3686400,
"duration": "300.000000",
"bits_per_raw_sample": "8",
"nb_frames": "7200",
"disposition": {
"default": 1,
"dub": 0,
"original": 0,
"comment": 0,
"lyrics": 0,
"karaoke": 0,
"forced": 0,
"hearing_impaired": 0,
"visual_impaired": 0,
"clean_effects": 0,
"attached_pic": 0,
"timed_thumbnails": 0
},
"tags": {
"language": "und",
"handler_name": "VideoHandler"
}
},
{
"index": 1,
"codec_name": "dts",
"codec_long_name": "DCA (DTS Coherent Acoustics)",
"profile": "DTS-HD MA",
"codec_type": "audio",
"codec_time_base": "1/48000",
"codec_tag_string": "[0][0][0][0]",
"codec_tag": "0x0000",
"sample_fmt": "s32p",
"sample_rate": "48000",
"bits_per_sample": 0,
"r_frame_rate": "0/0",
"avg_frame_rate": "0/0",
"time_base": "1/48000",
"start_pts": 0,
"start_time": "0.000000",
"duration_ts": 14400000,
"duration": "300.000000",
"nb_frames": "14063",
"disposition": {
"default": 1,
"dub": 0,
"original": 0,
"comment": 0,
"lyrics": 0,
"karaoke": 0,
"forced": 0,
"hearing_impaired": 0,
"visual_impaired": 0,
"clean_effects": 0,
"attached_pic": 0,
"timed_thumbnails": 0
},
"tags": {
"language": "eng",
"handler_name": "SoundHandler"
}
}
],
"format": {
"filename": "test.1080p.mp4",
"nb_streams": 2,
"nb_programs": 0,
"format_name": "mov,mp4,m4a,3gp,3g2,mj2",
"format_long_name": "QuickTime / MOV",
"start_time": "0.000000",
"duration": "300.000000",
"size": "192000000",
"bit_rate": "5128000",
"probe_score": 100,
"tags": {
"major_brand": "isom",
"minor_version": "512",
"compatible_brands": "isomiso2avc1mp41",
"encoder": "Lavf58.20.100"
}
}
}

View File

@@ -0,0 +1,49 @@
{
"streams": [
{
"index": 0,
"codec_name": "h264",
"codec_long_name": "H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10",
"profile": "High",
"codec_type": "video",
"codec_tag_string": "[0][0][0][0]",
"codec_tag": "0x0000",
"width": 1920,
"height": 1080,
"coded_width": 1920,
"coded_height": 1080,
"has_b_frames": 2,
"pix_fmt": "yuv420p",
"level": 40,
"r_frame_rate": "24/1",
"avg_frame_rate": "24/1",
"time_base": "1/1000",
"start_pts": 0,
"start_time": "0.000000",
"disposition": {
"default": 1
},
"tags": {
"language": "eng",
"BPS-eng": "",
"DURATION-eng": "00:01:40.000000000",
"NUMBER_OF_FRAMES-eng": "2400",
"NUMBER_OF_BYTES-eng": "10000000"
}
}
],
"format": {
"filename": "video_nanosecond_duration_bitrate.mkv",
"nb_streams": 1,
"nb_programs": 0,
"format_name": "matroska,webm",
"format_long_name": "Matroska / WebM",
"start_time": "0.000000",
"duration": "100.000000",
"size": "10001000",
"probe_score": 100,
"tags": {
"encoder": "libebml v1.4.2 + libmatroska v1.6.4"
}
}
}

View File

@@ -0,0 +1,233 @@
#pragma warning disable RS0030 // Do not use banned APIs: Guid == is required inside EF expression trees to mirror the production query shapes.
using System;
using System.Linq;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.Server.Implementations.Tests.Item;
/// <summary>
/// Verifies that the alternate-version-aware query shapes used by the resume filter
/// (BaseItemRepository.TranslateQuery) and the DatePlayed ordering (OrderMapper) translate
/// and evaluate correctly on the SQLite provider.
/// </summary>
public sealed class AlternateVersionQueryTranslationTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
public AlternateVersionQueryTranslationTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
using var ctx = CreateDbContext();
ctx.Database.EnsureCreated();
}
[Fact]
public void ResumeFilter_VersionProgress_SurfacesPlayedVersion()
{
Guid userId, primaryId, versionId, otherId;
using (var ctx = CreateDbContext())
{
(userId, primaryId, versionId, otherId) = Seed(ctx);
}
using (var ctx = CreateDbContext())
{
var inProgress = ctx.UserData
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
// Scope to the seeded items; EnsureCreated also seeds a placeholder row.
var seededIds = new[] { primaryId, versionId, otherId };
// Mirrors the resumable=true filter in BaseItemRepository.TranslateQuery.
var inProgressIds = inProgress.Select(ud => ud.ItemId);
var resumable = ctx.BaseItems
.Where(e => seededIds.Contains(e.Id))
.Where(e => inProgressIds.Contains(e.Id))
.Where(e => !ctx.BaseItems
.Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Any(s =>
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
|| (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
== inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
&& s.Id.CompareTo(e.Id) < 0)))
.Select(e => e.Id)
.ToList();
Assert.Equal([versionId], resumable);
// The not-resumable direction keeps primaries only.
var resumableMovieIds = inProgress
.Join(ctx.BaseItems, ud => ud.ItemId, bi => bi.Id, (ud, bi) => bi.PrimaryVersionId ?? bi.Id);
var notResumable = ctx.BaseItems
.Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null)
.Where(e => !resumableMovieIds.Contains(e.Id))
.Select(e => e.Id)
.ToList();
Assert.Equal([otherId], notResumable);
}
}
[Fact]
public void ResumeFilter_TiedLastPlayedDate_KeepsSingleVersion()
{
Guid userId, primaryId, versionAId, versionBId;
using (var ctx = CreateDbContext())
{
(userId, primaryId, versionAId, versionBId) = SeedTiedVersions(ctx);
}
using (var ctx = CreateDbContext())
{
var inProgress = ctx.UserData
.Where(ud => ud.UserId == userId && ud.PlaybackPositionTicks > 0);
var seededIds = new[] { primaryId, versionAId, versionBId };
var inProgressIds = inProgress.Select(ud => ud.ItemId);
// The exact production dedup, including the Guid.CompareTo tie-break. This asserts the
// expression translates on SQLite and that two versions sharing an identical LastPlayedDate
// collapse to a single row instead of double-listing the item in Continue Watching.
var resumable = ctx.BaseItems
.Where(e => seededIds.Contains(e.Id))
.Where(e => inProgressIds.Contains(e.Id))
.Where(e => !ctx.BaseItems
.Where(s => s.Id != e.Id && (s.PrimaryVersionId ?? s.Id) == (e.PrimaryVersionId ?? e.Id))
.Any(s =>
inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
> inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
|| (inProgress.Where(su => su.ItemId == s.Id).Max(su => su.LastPlayedDate)
== inProgress.Where(eu => eu.ItemId == e.Id).Max(eu => eu.LastPlayedDate)
&& s.Id.CompareTo(e.Id) < 0)))
.Select(e => e.Id)
.ToList();
var survivor = Assert.Single(resumable);
Assert.Contains(survivor, new[] { versionAId, versionBId });
}
}
[Fact]
public void DatePlayedOrdering_VersionProgress_SortsPrimaryByVersionDate()
{
Guid userId, primaryId, otherId;
using (var ctx = CreateDbContext())
{
(userId, primaryId, _, otherId) = Seed(ctx);
}
using (var ctx = CreateDbContext())
{
// Scope to the seeded items; EnsureCreated also seeds a placeholder row.
var seededIds = new[] { primaryId, otherId };
// Mirrors the DatePlayed mapping in OrderMapper.
var ordered = ctx.BaseItems
.Where(e => seededIds.Contains(e.Id) && e.PrimaryVersionId == null)
.OrderByDescending(e => ctx.UserData
.Where(w => w.UserId == userId && (w.ItemId == e.Id || w.Item!.PrimaryVersionId == e.Id))
.Max(f => f.LastPlayedDate))
.Select(e => e.Id)
.ToList();
// The movie whose only progress is on its alternate version sorts before the unplayed one.
Assert.Equal([primaryId, otherId], ordered);
}
}
private static (Guid UserId, Guid PrimaryId, Guid VersionId, Guid OtherId) Seed(JellyfinDbContext ctx)
{
var user = new User("test", "auth-provider", "reset-provider");
ctx.Users.Add(user);
var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
var version = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
var other = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
ctx.BaseItems.AddRange(primary, version, other);
// Progress only on the alternate version.
ctx.UserData.Add(new UserData
{
ItemId = version.Id,
Item = version,
UserId = user.Id,
User = user,
CustomDataKey = version.Id.ToString("N"),
PlaybackPositionTicks = 1000,
LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc)
});
ctx.SaveChanges();
return (user.Id, primary.Id, version.Id, other.Id);
}
private static (Guid UserId, Guid PrimaryId, Guid VersionAId, Guid VersionBId) SeedTiedVersions(JellyfinDbContext ctx)
{
var user = new User("test", "auth-provider", "reset-provider");
ctx.Users.Add(user);
var primary = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie" };
var versionA = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
var versionB = new BaseItemEntity { Id = Guid.NewGuid(), Type = "MediaBrowser.Controller.Entities.Movies.Movie", PrimaryVersionId = primary.Id };
ctx.BaseItems.AddRange(primary, versionA, versionB);
// Both versions in progress with the exact same LastPlayedDate - the tie that a strict '>' cannot break.
var tied = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc);
ctx.UserData.Add(new UserData
{
ItemId = versionA.Id,
Item = versionA,
UserId = user.Id,
User = user,
CustomDataKey = versionA.Id.ToString("N"),
PlaybackPositionTicks = 1000,
LastPlayedDate = tied
});
ctx.UserData.Add(new UserData
{
ItemId = versionB.Id,
Item = versionB,
UserId = user.Id,
User = user,
CustomDataKey = versionB.Id.ToString("N"),
PlaybackPositionTicks = 2000,
LastPlayedDate = tied
});
ctx.SaveChanges();
return (user.Id, primary.Id, versionA.Id, versionB.Id);
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
public void Dispose()
{
_connection.Dispose();
}
}

View File

@@ -0,0 +1,144 @@
using System;
using System.Linq;
using Emby.Server.Implementations.Data;
using Jellyfin.Database.Implementations;
using Jellyfin.Database.Implementations.Entities;
using Jellyfin.Database.Implementations.Locking;
using Jellyfin.Database.Providers.Sqlite;
using Jellyfin.Server.Implementations.Item;
using MediaBrowser.Controller;
using MediaBrowser.Controller.Configuration;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Model.Configuration;
using MediaBrowser.Model.Entities;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
using BaseItemKind = Jellyfin.Data.Enums.BaseItemKind;
namespace Jellyfin.Server.Implementations.Tests.Item;
public sealed class BaseItemRepositoryGroupingTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<JellyfinDbContext> _dbOptions;
private readonly BaseItemRepository _repository;
private readonly string _movieTypeName;
public BaseItemRepositoryGroupingTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_dbOptions = new DbContextOptionsBuilder<JellyfinDbContext>()
.UseSqlite(_connection)
.Options;
using (var ctx = CreateDbContext())
{
ctx.Database.EnsureCreated();
}
var factory = new Mock<IDbContextFactory<JellyfinDbContext>>();
factory.Setup(f => f.CreateDbContext()).Returns(CreateDbContext);
var itemTypeLookup = new ItemTypeLookup();
_movieTypeName = itemTypeLookup.BaseItemKindNames[BaseItemKind.Movie];
var serverConfigurationManager = new Mock<IServerConfigurationManager>();
serverConfigurationManager.Setup(c => c.Configuration).Returns(new ServerConfiguration());
_repository = new BaseItemRepository(
factory.Object,
new Mock<IServerApplicationHost>().Object,
itemTypeLookup,
serverConfigurationManager.Object,
NullLogger<BaseItemRepository>.Instance);
}
public void Dispose()
{
_connection.Dispose();
}
[Fact]
public void GetItemList_VersionGroup_ReturnsPrimaryVersion()
{
// The alternate version sorts before the primary by id, so a plain Min(Id) per
// presentation key would wrongly pick the alternate as the group representative.
var primaryId = Guid.Parse("eeeeeeee-eeee-eeee-eeee-eeeeeeeeeeee");
var alternateId = Guid.Parse("11111111-1111-1111-1111-111111111111");
var presentationKey = primaryId.ToString("N");
using (var ctx = CreateDbContext())
{
ctx.BaseItems.Add(CreateMovieEntity(primaryId, "Movie", presentationKey, null));
ctx.BaseItems.Add(CreateMovieEntity(alternateId, "Movie - 1080p", presentationKey, primaryId));
ctx.SaveChanges();
}
var result = _repository.GetItemList(CreateQuery());
var item = Assert.Single(result);
Assert.Equal(primaryId, item.Id);
}
[Fact]
public void GetItemList_GroupWithoutPrimary_FallsBackToMinId()
{
var firstId = Guid.Parse("22222222-2222-2222-2222-222222222222");
var secondId = Guid.Parse("dddddddd-dddd-dddd-dddd-dddddddddddd");
var otherPrimaryId = Guid.Parse("cccccccc-cccc-cccc-cccc-cccccccccccc");
var presentationKey = otherPrimaryId.ToString("N");
using (var ctx = CreateDbContext())
{
ctx.BaseItems.Add(CreateMovieEntity(firstId, "Movie", presentationKey, otherPrimaryId));
ctx.BaseItems.Add(CreateMovieEntity(secondId, "Movie - 4K", presentationKey, otherPrimaryId));
ctx.SaveChanges();
}
var result = _repository.GetItemList(CreateQuery());
var item = Assert.Single(result);
Assert.Equal(firstId, item.Id);
}
private static InternalItemsQuery CreateQuery()
{
// IncludeOwnedItems keeps the alternate version rows in the query so the
// grouping collapse is what picks the group representative.
return new InternalItemsQuery(new Database.Implementations.Entities.User("test", "auth", "reset"))
{
IncludeItemTypes = [BaseItemKind.Movie],
IncludeOwnedItems = true
};
}
private BaseItemEntity CreateMovieEntity(Guid id, string name, string presentationKey, Guid? primaryVersionId)
{
return new BaseItemEntity
{
Id = id,
Type = _movieTypeName,
Name = name,
PresentationUniqueKey = presentationKey,
PrimaryVersionId = primaryVersionId,
MediaType = "Video",
IsMovie = true,
IsFolder = false,
IsVirtualItem = false
};
}
private JellyfinDbContext CreateDbContext()
{
return new JellyfinDbContext(
_dbOptions,
NullLogger<JellyfinDbContext>.Instance,
new SqliteDatabaseProvider(null!, NullLogger<SqliteDatabaseProvider>.Instance),
new NoLockBehavior(NullLogger<NoLockBehavior>.Instance));
}
}

View File

@@ -1,4 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AutoFixture;
using AutoFixture.AutoMoq;
using Castle.Components.DictionaryAdapter;
@@ -7,6 +9,8 @@ using Emby.Server.Implementations.Library;
using Jellyfin.Database.Implementations.Entities;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Controller.LiveTv;
using MediaBrowser.Controller.MediaSegments;
using MediaBrowser.Model.Dto;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
@@ -144,5 +148,111 @@ namespace Jellyfin.Server.Implementations.Tests.Library
_mediaSourceManager.SetDefaultAudioAndSubtitleStreamIndices(_item, mediaInfo, _user);
Assert.Equal(expectedIndex, mediaInfo.DefaultAudioStreamIndex);
}
[Fact]
public void GetStaticMediaSources_PrimaryQueried_PopulatesPerVersionPositionsAndDefaultsToMostRecent()
{
var (primary, alt1, alt2) = SetupVersionGroup();
SetupUserDataBatch(new Dictionary<Guid, UserItemData>
{
[alt1.Id] = new UserItemData { Key = "alt1", PlaybackPositionTicks = 10, LastPlayedDate = new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc) },
[alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) }
});
var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
// Each version carries its own resume point; the primary has none.
Assert.Equal((long?)10, sources.First(s => s.Id == alt1.Id.ToString("N")).PlaybackPositionTicks);
Assert.Equal((long?)20, sources.First(s => s.Id == alt2.Id.ToString("N")).PlaybackPositionTicks);
Assert.Null(sources.First(s => s.Id == primary.Id.ToString("N")).PlaybackPositionTicks);
// The most recently played version is the default source, so resuming plays the right file.
Assert.Equal(alt2.Id.ToString("N"), sources[0].Id);
}
[Fact]
public void GetStaticMediaSources_AlternateQueried_KeepsOwnSourceFirst()
{
var (primary, alt1, alt2) = SetupVersionGroup();
SetupUserDataBatch(new Dictionary<Guid, UserItemData>
{
[alt2.Id] = new UserItemData { Key = "alt2", PlaybackPositionTicks = 20, LastPlayedDate = new DateTime(2026, 1, 2, 0, 0, 0, DateTimeKind.Utc) }
});
var sources = _mediaSourceManager.GetStaticMediaSources(alt1, false, _user);
// An explicitly opened version keeps its own source first, even when a sibling was
// played more recently, but the sibling's resume point is still populated.
Assert.Equal(alt1.Id.ToString("N"), sources[0].Id);
Assert.Equal((long?)20, sources.First(s => s.Id == alt2.Id.ToString("N")).PlaybackPositionTicks);
Assert.Equal(3, sources.Count);
}
[Fact]
public void GetStaticMediaSources_NoProgress_KeepsQueriedItemFirst()
{
var (primary, _, _) = SetupVersionGroup();
SetupUserDataBatch([]);
var sources = _mediaSourceManager.GetStaticMediaSources(primary, false, _user);
Assert.Equal(primary.Id.ToString("N"), sources[0].Id);
Assert.All(sources, s => Assert.Null(s.PlaybackPositionTicks));
}
[Fact]
public void GetStaticMediaSources_NoUser_DoesNotTouchUserData()
{
var (primary, _, _) = SetupVersionGroup();
var sources = _mediaSourceManager.GetStaticMediaSources(primary, false);
Assert.Equal(primary.Id.ToString("N"), sources[0].Id);
_mockUserDataManager.Verify(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()), Times.Never);
}
private void SetupUserDataBatch(Dictionary<Guid, UserItemData> userData)
{
_mockUserDataManager
.Setup(x => x.GetUserDataBatch(It.IsAny<IReadOnlyList<BaseItem>>(), It.IsAny<User>()))
.Returns((IReadOnlyList<BaseItem> items, User _) => items
.Where(i => userData.ContainsKey(i.Id))
.ToDictionary(i => i.Id, i => userData[i.Id]));
}
private static (Video Primary, Video Alt1, Video Alt2) SetupVersionGroup()
{
var primary = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie.mkv" };
var alt1 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 1080p.mkv", PrimaryVersionId = primary.Id };
var alt2 = new Video { Id = Guid.NewGuid(), Path = "/Movies/Movie/Movie - 4K.mkv", PrimaryVersionId = primary.Id };
// BaseItem.GetMediaSources runs against the static service locators.
var mediaSourceManager = new Mock<IMediaSourceManager>();
mediaSourceManager.Setup(x => x.GetPathProtocol(It.IsAny<string>())).Returns(MediaProtocol.File);
mediaSourceManager.Setup(x => x.GetMediaStreams(It.IsAny<Guid>())).Returns(new List<MediaStream>());
mediaSourceManager.Setup(x => x.GetMediaAttachments(It.IsAny<Guid>())).Returns(new List<MediaAttachment>());
var segmentManager = new Mock<IMediaSegmentManager>();
segmentManager.Setup(x => x.IsTypeSupported(It.IsAny<BaseItem>())).Returns(false);
var libraryManager = new Mock<ILibraryManager>();
libraryManager.Setup(x => x.GetLinkedAlternateVersions(It.IsAny<Video>())).Returns(Array.Empty<Video>());
libraryManager.Setup(x => x.GetLocalAlternateVersionIds(primary)).Returns(new[] { alt1.Id, alt2.Id });
libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt1)).Returns(Array.Empty<Guid>());
libraryManager.Setup(x => x.GetLocalAlternateVersionIds(alt2)).Returns(Array.Empty<Guid>());
libraryManager.Setup(x => x.GetItemById(primary.Id)).Returns(primary);
libraryManager.Setup(x => x.GetItemById(alt1.Id)).Returns(alt1);
libraryManager.Setup(x => x.GetItemById(alt2.Id)).Returns(alt2);
var recordingsManager = new Mock<IRecordingsManager>();
recordingsManager.Setup(x => x.GetActiveRecordingInfo(It.IsAny<string>())).Returns((ActiveRecordingInfo?)null);
BaseItem.MediaSegmentManager = segmentManager.Object;
BaseItem.MediaSourceManager = mediaSourceManager.Object;
BaseItem.LibraryManager = libraryManager.Object;
Video.RecordingsManager = recordingsManager.Object;
return (primary, alt1, alt2);
}
}
}

View File

@@ -1,6 +1,5 @@
using System;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
@@ -164,5 +163,33 @@ namespace Jellyfin.Server.Integration.Tests.Controllers
using var response = await UpdateUserPassword(client, _testUserId, createRequest);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
}
[Fact]
[Priority(2)]
public async Task UpdateUser_UsernameCaseDifference_Success()
{
var client = _factory.CreateClient();
client.DefaultRequestHeaders.AddAuthHeader(_accessToken!);
using var response = await client.GetAsync("Users/" + _testUserId, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var userDto = await response.Content.ReadFromJsonAsync<UserDto>(JsonDefaults.Options, TestContext.Current.CancellationToken);
Assert.NotNull(userDto);
userDto.Name = userDto.Name.ToLowerInvariant();
using var response2 = await client.PostAsJsonAsync($"Users?userId={_testUserId}", userDto, _jsonOptions, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, response2.StatusCode);
using var response3 = await client.GetAsync("Users/" + _testUserId, TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var newUserDto = await response3.Content.ReadFromJsonAsync<UserDto>(JsonDefaults.Options, TestContext.Current.CancellationToken);
Assert.NotNull(newUserDto);
Assert.Equal(userDto.Name, newUserDto.Name);
// Sanity check, make sure we're testing something
Assert.NotEqual(TestUsername, userDto.Name);
}
}
}

View File

@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Json;
using System.Net.WebSockets;
using System.Reflection;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Emby.Server.Implementations.Session;
using Jellyfin.Api.Models.SyncPlayDtos;
using Jellyfin.Extensions.Json;
using MediaBrowser.Controller.Net;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Jellyfin.Server.Integration.Tests;
public sealed class SyncPlayLostWebSocketTests : IClassFixture<JellyfinApplicationFactory>
{
private readonly JellyfinApplicationFactory _factory;
public SyncPlayLostWebSocketTests(JellyfinApplicationFactory factory)
{
_factory = factory;
}
[Fact]
public async Task LostWebSocket_EndsSession_And_RemovesEmptySyncPlayGroup()
{
var cancellationToken = TestContext.Current.CancellationToken;
var client = _factory.CreateClient();
var accessToken = await AuthHelper.CompleteStartupAsync(client);
client.DefaultRequestHeaders.AddAuthHeader(accessToken);
var wsClient = _factory.Server.CreateWebSocketClient();
wsClient.ConfigureRequest = request =>
request.Headers.Authorization = AuthHelper.DummyAuthHeader + $", Token={accessToken}";
var webSocket = await wsClient.ConnectAsync(
new UriBuilder(_factory.Server.BaseAddress)
{
Scheme = "ws",
Path = "websocket"
}.Uri,
cancellationToken);
_ = DrainAsync(webSocket, cancellationToken);
var watched = await WaitForWatchedWebSocketsAsync(TimeSpan.FromSeconds(10), cancellationToken);
var connection = Assert.Single(watched);
using var createResponse = await client.PostAsync(
"SyncPlay/New",
JsonContent.Create(new NewGroupRequestDto { GroupName = "ZombieGroupRepro" }, options: JsonDefaults.Options),
cancellationToken);
Assert.Equal(HttpStatusCode.OK, createResponse.StatusCode);
Assert.Equal(1, await WaitForGroupCountAsync(client, 1, TimeSpan.FromSeconds(10), cancellationToken));
connection.LastKeepAliveDate = DateTime.UtcNow - TimeSpan.FromSeconds(180);
var groupCount = await WaitForGroupCountAsync(client, 0, TimeSpan.FromSeconds(45), cancellationToken);
Assert.True(
groupCount == 0,
$"SyncPlay group still listed {groupCount} group(s) after the WebSocket was lost: "
+ "the keep-alive watchdog removed the socket from its watchlist without closing "
+ "the session, leaving a zombie participant in the group (SessionWebSocketListener).");
}
private static async Task DrainAsync(WebSocket webSocket, CancellationToken cancellationToken)
{
var buffer = new byte[4096];
try
{
while (webSocket.State == WebSocketState.Open)
{
await webSocket.ReceiveAsync(buffer, cancellationToken);
}
}
catch
{
// The server tears the connection down once the watchdog gives up on it.
}
}
private async Task<IReadOnlyList<IWebSocketConnection>> WaitForWatchedWebSocketsAsync(TimeSpan timeout, CancellationToken cancellationToken)
{
var listener = _factory.Services.GetRequiredService<IEnumerable<IWebSocketListener>>()
.OfType<SessionWebSocketListener>()
.Single();
var watchlistField = typeof(SessionWebSocketListener)
.GetField("_webSockets", BindingFlags.NonPublic | BindingFlags.Instance);
Assert.NotNull(watchlistField);
var watchlist = (IEnumerable<IWebSocketConnection>)watchlistField.GetValue(listener)!;
var stopwatch = Stopwatch.StartNew();
while (true)
{
try
{
var snapshot = watchlist.ToArray();
if (snapshot.Length > 0 || stopwatch.Elapsed >= timeout)
{
return snapshot;
}
}
catch (InvalidOperationException)
{
// The watchdog mutated the set during enumeration; retry.
}
await Task.Delay(100, cancellationToken);
}
}
private static async Task<int> WaitForGroupCountAsync(HttpClient client, int expected, TimeSpan timeout, CancellationToken cancellationToken)
{
var stopwatch = Stopwatch.StartNew();
var count = -1;
while (stopwatch.Elapsed < timeout)
{
using var response = await client.GetAsync("SyncPlay/List", cancellationToken);
response.EnsureSuccessStatusCode();
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
count = document.RootElement.GetArrayLength();
if (count == expected)
{
return count;
}
await Task.Delay(500, cancellationToken);
}
return count;
}
}