aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.github/workflows/ci-codeql-analysis.yml8
-rw-r--r--.github/workflows/ci-compat.yml12
-rw-r--r--.github/workflows/ci-openapi.yml16
-rw-r--r--.github/workflows/ci-tests.yml2
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs15
-rw-r--r--Emby.Server.Implementations/Library/MediaStreamSelector.cs86
-rw-r--r--Emby.Server.Implementations/Localization/Core/el.json2
-rw-r--r--Emby.Server.Implementations/Localization/Core/pt-PT.json2
-rw-r--r--Emby.Server.Implementations/TV/TVSeriesManager.cs61
-rw-r--r--Jellyfin.Api/Controllers/TvShowsController.cs4
-rw-r--r--Jellyfin.Server.Implementations/Item/BaseItemRepository.cs31
-rw-r--r--MediaBrowser.Controller/Library/ILibraryManager.cs9
-rw-r--r--MediaBrowser.Controller/Persistence/IItemRepository.cs8
-rw-r--r--MediaBrowser.Model/Querying/NextUpQuery.cs113
14 files changed, 209 insertions, 160 deletions
diff --git a/.github/workflows/ci-codeql-analysis.yml b/.github/workflows/ci-codeql-analysis.yml
index 850214fec..6a3d4d351 100644
--- a/.github/workflows/ci-codeql-analysis.yml
+++ b/.github/workflows/ci-codeql-analysis.yml
@@ -22,16 +22,16 @@ jobs:
- name: Checkout repository
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Setup .NET
- uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '9.0.x'
- name: Initialize CodeQL
- uses: github/codeql-action/init@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
+ uses: github/codeql-action/init@5f8171a638ada777af81d42b55959a643bb29017 # v3.28.12
with:
languages: ${{ matrix.language }}
queries: +security-extended
- name: Autobuild
- uses: github/codeql-action/autobuild@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
+ uses: github/codeql-action/autobuild@5f8171a638ada777af81d42b55959a643bb29017 # v3.28.12
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@6bb031afdd8eb862ea3fc1848194185e076637e5 # v3.28.11
+ uses: github/codeql-action/analyze@5f8171a638ada777af81d42b55959a643bb29017 # v3.28.12
diff --git a/.github/workflows/ci-compat.yml b/.github/workflows/ci-compat.yml
index ca505790c..13b029e52 100644
--- a/.github/workflows/ci-compat.yml
+++ b/.github/workflows/ci-compat.yml
@@ -17,7 +17,7 @@ jobs:
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
- uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '9.0.x'
@@ -26,7 +26,7 @@ jobs:
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: abi-head
retention-days: 14
@@ -47,7 +47,7 @@ jobs:
fetch-depth: 0
- name: Setup .NET
- uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '9.0.x'
@@ -65,7 +65,7 @@ jobs:
dotnet build Jellyfin.Server -o ./out
- name: Upload Head
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: abi-base
retention-days: 14
@@ -85,13 +85,13 @@ jobs:
steps:
- name: Download abi-head
- uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
+ uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: abi-head
path: abi-head
- name: Download abi-base
- uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
+ uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: abi-base
path: abi-base
diff --git a/.github/workflows/ci-openapi.yml b/.github/workflows/ci-openapi.yml
index 5a3284987..95e090f9b 100644
--- a/.github/workflows/ci-openapi.yml
+++ b/.github/workflows/ci-openapi.yml
@@ -21,13 +21,13 @@ jobs:
ref: ${{ github.event.pull_request.head.sha }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- name: Setup .NET
- uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '9.0.x'
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: openapi-head
retention-days: 14
@@ -55,13 +55,13 @@ jobs:
ANCESTOR_REF=$(git merge-base upstream/${{ github.base_ref }} origin/$HEAD_REF)
git checkout --progress --force $ANCESTOR_REF
- name: Setup .NET
- uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
+ uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: '9.0.x'
- name: Generate openapi.json
run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests"
- name: Upload openapi.json
- uses: actions/upload-artifact@4cec3d8aa04e39d1a68397de0c4cd6fb9dce8ec1 # v4.6.1
+ uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: openapi-base
retention-days: 14
@@ -80,12 +80,12 @@ jobs:
- openapi-base
steps:
- name: Download openapi-head
- uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
+ uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: openapi-head
path: openapi-head
- name: Download openapi-base
- uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
+ uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: openapi-base
path: openapi-base
@@ -158,7 +158,7 @@ jobs:
run: |-
echo "JELLYFIN_VERSION=$(date +'%Y%m%d%H%M%S')" >> $GITHUB_ENV
- name: Download openapi-head
- uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
+ uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: openapi-head
path: openapi-head
@@ -220,7 +220,7 @@ jobs:
run: |-
echo "JELLYFIN_VERSION=${GITHUB_REF#refs/tags/v}" >> $GITHUB_ENV
- name: Download openapi-head
- uses: actions/download-artifact@cc203385981b70ca67e1cc392babf9cc229d5806 # v4.1.9
+ uses: actions/download-artifact@95815c38cf2ff2164869cbab79da8d1f422bc89e # v4.2.1
with:
name: openapi-head
path: openapi-head
diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml
index ec78396db..04c8465e9 100644
--- a/.github/workflows/ci-tests.yml
+++ b/.github/workflows/ci-tests.yml
@@ -22,7 +22,7 @@ jobs:
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- - uses: actions/setup-dotnet@3951f0dfe7a07e2313ec93c75700083e2005cbab # v4.3.0
+ - uses: actions/setup-dotnet@67a3573c9a986a3f9c594539f4ab511d57bb3ce9 # v4.3.1
with:
dotnet-version: ${{ env.SDK_VERSION }}
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index cc2092e21..7b3a54039 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -1344,6 +1344,21 @@ namespace Emby.Server.Implementations.Library
return _itemRepository.GetItemList(query);
}
+ public IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents, DateTime dateCutoff)
+ {
+ SetTopParentIdsOrAncestors(query, parents);
+
+ if (query.AncestorIds.Length == 0 && query.TopParentIds.Length == 0)
+ {
+ if (query.User is not null)
+ {
+ AddUserToQuery(query, query.User);
+ }
+ }
+
+ return _itemRepository.GetNextUpSeriesKeys(query, dateCutoff);
+ }
+
public QueryResult<BaseItem> QueryItems(InternalItemsQuery query)
{
if (query.User is not null)
diff --git a/Emby.Server.Implementations/Library/MediaStreamSelector.cs b/Emby.Server.Implementations/Library/MediaStreamSelector.cs
index ea223e3ec..6791e3ca9 100644
--- a/Emby.Server.Implementations/Library/MediaStreamSelector.cs
+++ b/Emby.Server.Implementations/Library/MediaStreamSelector.cs
@@ -39,46 +39,48 @@ namespace Emby.Server.Implementations.Library
return null;
}
+ // Sort in the following order: Default > No tag > Forced
var sortedStreams = streams
.Where(i => i.Type == MediaStreamType.Subtitle)
.OrderByDescending(x => x.IsExternal)
- .ThenByDescending(x => x.IsForced && string.Equals(x.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase))
- .ThenByDescending(x => x.IsForced)
.ThenByDescending(x => x.IsDefault)
- .ThenByDescending(x => preferredLanguages.Contains(x.Language, StringComparison.OrdinalIgnoreCase))
+ .ThenByDescending(x => !x.IsForced && MatchesPreferredLanguage(x.Language, preferredLanguages))
+ .ThenByDescending(x => x.IsForced && MatchesPreferredLanguage(x.Language, preferredLanguages))
+ .ThenByDescending(x => x.IsForced && IsLanguageUndefined(x.Language))
+ .ThenByDescending(x => x.IsForced)
.ToList();
MediaStream? stream = null;
+
if (mode == SubtitlePlaybackMode.Default)
{
- // Load subtitles according to external, forced and default flags.
- stream = sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsForced || x.IsDefault);
+ // Load subtitles according to external, default and forced flags.
+ stream = sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsDefault || x.IsForced);
}
else if (mode == SubtitlePlaybackMode.Smart)
{
// Only attempt to load subtitles if the audio language is not one of the user's preferred subtitle languages.
- // If no subtitles of preferred language available, use default behaviour.
+ // If no subtitles of preferred language available, use none.
+ // If the audio language is one of the user's preferred subtitle languages behave like OnlyForced.
if (!preferredLanguages.Contains(audioTrackLanguage, StringComparison.OrdinalIgnoreCase))
{
- stream = sortedStreams.FirstOrDefault(x => preferredLanguages.Contains(x.Language, StringComparison.OrdinalIgnoreCase)) ??
- sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsForced || x.IsDefault);
+ stream = sortedStreams.FirstOrDefault(x => MatchesPreferredLanguage(x.Language, preferredLanguages));
}
else
{
- // Respect forced flag.
- stream = sortedStreams.FirstOrDefault(x => x.IsForced);
+ stream = BehaviorOnlyForced(sortedStreams, preferredLanguages).FirstOrDefault();
}
}
else if (mode == SubtitlePlaybackMode.Always)
{
- // Always load (full/non-forced) subtitles of the user's preferred subtitle language if possible, otherwise default behaviour.
- stream = sortedStreams.FirstOrDefault(x => !x.IsForced && preferredLanguages.Contains(x.Language, StringComparison.OrdinalIgnoreCase)) ??
- sortedStreams.FirstOrDefault(x => x.IsExternal || x.IsForced || x.IsDefault);
+ // Always load (full/non-forced) subtitles of the user's preferred subtitle language if possible, otherwise OnlyForced behaviour.
+ stream = sortedStreams.FirstOrDefault(x => !x.IsForced && MatchesPreferredLanguage(x.Language, preferredLanguages)) ??
+ BehaviorOnlyForced(sortedStreams, preferredLanguages).FirstOrDefault();
}
else if (mode == SubtitlePlaybackMode.OnlyForced)
{
- // Only load subtitles that are flagged forced.
- stream = sortedStreams.FirstOrDefault(x => x.IsForced);
+ // Load subtitles that are flagged forced of the user's preferred subtitle language or with an undefined language
+ stream = BehaviorOnlyForced(sortedStreams, preferredLanguages).FirstOrDefault();
}
return stream?.Index;
@@ -110,40 +112,72 @@ namespace Emby.Server.Implementations.Library
if (mode == SubtitlePlaybackMode.Default)
{
// Prefer embedded metadata over smart logic
- filteredStreams = sortedStreams.Where(s => s.IsForced || s.IsDefault)
+ // Load subtitles according to external, default, and forced flags.
+ filteredStreams = sortedStreams.Where(s => s.IsExternal || s.IsDefault || s.IsForced)
.ToList();
}
else if (mode == SubtitlePlaybackMode.Smart)
{
// Prefer smart logic over embedded metadata
+ // Only attempt to load subtitles if the audio language is not one of the user's preferred subtitle languages, otherwise OnlyForced behavior.
if (!preferredLanguages.Contains(audioTrackLanguage, StringComparison.OrdinalIgnoreCase))
{
- filteredStreams = sortedStreams.Where(s => !s.IsForced && preferredLanguages.Contains(s.Language, StringComparison.OrdinalIgnoreCase))
+ filteredStreams = sortedStreams.Where(s => MatchesPreferredLanguage(s.Language, preferredLanguages))
.ToList();
}
+ else
+ {
+ filteredStreams = BehaviorOnlyForced(sortedStreams, preferredLanguages);
+ }
}
else if (mode == SubtitlePlaybackMode.Always)
{
- // Always load the most suitable full subtitles
- filteredStreams = sortedStreams.Where(s => !s.IsForced).ToList();
+ // Always load (full/non-forced) subtitles of the user's preferred subtitle language if possible, otherwise OnlyForced behavior.
+ filteredStreams = sortedStreams.Where(s => !s.IsForced && MatchesPreferredLanguage(s.Language, preferredLanguages))
+ .ToList() ?? BehaviorOnlyForced(sortedStreams, preferredLanguages);
}
else if (mode == SubtitlePlaybackMode.OnlyForced)
{
- // Always load the most suitable full subtitles
- filteredStreams = sortedStreams.Where(s => s.IsForced).ToList();
+ // Load subtitles that are flagged forced of the user's preferred subtitle language or with an undefined language
+ filteredStreams = BehaviorOnlyForced(sortedStreams, preferredLanguages);
}
- // Load forced subs if we have found no suitable full subtitles
- var iterStreams = filteredStreams is null || filteredStreams.Count == 0
- ? sortedStreams.Where(s => s.IsForced && string.Equals(s.Language, audioTrackLanguage, StringComparison.OrdinalIgnoreCase))
- : filteredStreams;
+ // If filteredStreams is null, initialize it as an empty list to avoid null reference errors
+ filteredStreams ??= new List<MediaStream>();
- foreach (var stream in iterStreams)
+ foreach (var stream in filteredStreams)
{
stream.Score = GetStreamScore(stream, preferredLanguages);
}
}
+ private static bool MatchesPreferredLanguage(string language, IReadOnlyList<string> preferredLanguages)
+ {
+ // If preferredLanguages is empty, treat it as "any language" (wildcard)
+ return preferredLanguages.Count == 0 ||
+ preferredLanguages.Contains(language, StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static bool IsLanguageUndefined(string language)
+ {
+ // Check for null, empty, or known placeholders
+ return string.IsNullOrEmpty(language) ||
+ language.Equals("und", StringComparison.OrdinalIgnoreCase) ||
+ language.Equals("unknown", StringComparison.OrdinalIgnoreCase) ||
+ language.Equals("undetermined", StringComparison.OrdinalIgnoreCase) ||
+ language.Equals("mul", StringComparison.OrdinalIgnoreCase) ||
+ language.Equals("zxx", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static List<MediaStream> BehaviorOnlyForced(IEnumerable<MediaStream> sortedStreams, IReadOnlyList<string> preferredLanguages)
+ {
+ return sortedStreams
+ .Where(s => s.IsForced && (MatchesPreferredLanguage(s.Language, preferredLanguages) || IsLanguageUndefined(s.Language)))
+ .OrderByDescending(s => MatchesPreferredLanguage(s.Language, preferredLanguages))
+ .ThenByDescending(s => IsLanguageUndefined(s.Language))
+ .ToList();
+ }
+
internal static int GetStreamScore(MediaStream stream, IReadOnlyList<string> languagePreferences)
{
var index = languagePreferences.FindIndex(x => string.Equals(x, stream.Language, StringComparison.OrdinalIgnoreCase));
diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json
index 631e659d5..f3195f0ea 100644
--- a/Emby.Server.Implementations/Localization/Core/el.json
+++ b/Emby.Server.Implementations/Localization/Core/el.json
@@ -125,7 +125,7 @@
"TaskKeyframeExtractor": "Εξαγωγέας βασικών καρέ βίντεο",
"External": "Εξωτερικό",
"HearingImpaired": "Με προβλήματα ακοής",
- "TaskRefreshTrickplayImages": "Δημιουργήστε εικόνες Trickplay",
+ "TaskRefreshTrickplayImages": "Δημιουργία εικόνων Trickplay",
"TaskRefreshTrickplayImagesDescription": "Δημιουργεί προεπισκοπήσεις trickplay για βίντεο σε ενεργοποιημένες βιβλιοθήκες.",
"TaskAudioNormalization": "Ομοιομορφία ήχου",
"TaskAudioNormalizationDescription": "Ανίχνευση αρχείων για δεδομένα ομοιομορφίας ήχου.",
diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json
index 879bf64b0..42ea5e0a4 100644
--- a/Emby.Server.Implementations/Localization/Core/pt-PT.json
+++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json
@@ -1,6 +1,6 @@
{
"Albums": "Álbuns",
- "AppDeviceValues": "Aplicação {0}, Dispositivo: {1}",
+ "AppDeviceValues": "Aplicação: {0}, Dispositivo: {1}",
"Application": "Aplicação",
"Artists": "Artistas",
"AuthenticationSucceededWithUserName": "{0} autenticado com sucesso",
diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs
index f8ce473da..10d27498b 100644
--- a/Emby.Server.Implementations/TV/TVSeriesManager.cs
+++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs
@@ -91,7 +91,7 @@ namespace Emby.Server.Implementations.TV
if (!string.IsNullOrEmpty(presentationUniqueKey))
{
- return GetResult(GetNextUpEpisodes(request, user, new[] { presentationUniqueKey }, options), request);
+ return GetResult(GetNextUpEpisodes(request, user, [presentationUniqueKey], options), request);
}
if (limit.HasValue)
@@ -99,25 +99,9 @@ namespace Emby.Server.Implementations.TV
limit = limit.Value + 10;
}
- var items = _libraryManager
- .GetItemList(
- new InternalItemsQuery(user)
- {
- IncludeItemTypes = new[] { BaseItemKind.Episode },
- OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending) },
- SeriesPresentationUniqueKey = presentationUniqueKey,
- Limit = limit,
- DtoOptions = new DtoOptions { Fields = new[] { ItemFields.SeriesPresentationUniqueKey }, EnableImages = false },
- GroupBySeriesPresentationUniqueKey = true
- },
- parentsFolders.ToList())
- .Cast<Episode>()
- .Where(episode => !string.IsNullOrEmpty(episode.SeriesPresentationUniqueKey))
- .Select(GetUniqueSeriesKey)
- .ToList();
-
- // Avoid implicitly captured closure
- var episodes = GetNextUpEpisodes(request, user, items.Distinct().ToArray(), options);
+ var nextUpSeriesKeys = _libraryManager.GetNextUpSeriesKeys(new InternalItemsQuery(user) { Limit = limit }, parentsFolders, request.NextUpDateCutoff);
+
+ var episodes = GetNextUpEpisodes(request, user, nextUpSeriesKeys, options);
return GetResult(episodes, request);
}
@@ -133,36 +117,11 @@ namespace Emby.Server.Implementations.TV
.OrderByDescending(i => i.LastWatchedDate);
}
- // If viewing all next up for all series, remove first episodes
- // But if that returns empty, keep those first episodes (avoid completely empty view)
- var alwaysEnableFirstEpisode = !request.SeriesId.IsNullOrEmpty();
- var anyFound = false;
-
return allNextUp
- .Where(i =>
- {
- if (request.DisableFirstEpisode)
- {
- return i.LastWatchedDate != DateTime.MinValue;
- }
-
- if (alwaysEnableFirstEpisode || (i.LastWatchedDate != DateTime.MinValue && i.LastWatchedDate.Date >= request.NextUpDateCutoff))
- {
- anyFound = true;
- return true;
- }
-
- return !anyFound && i.LastWatchedDate == DateTime.MinValue;
- })
.Select(i => i.GetEpisodeFunction())
.Where(i => i is not null)!;
}
- private static string GetUniqueSeriesKey(Episode episode)
- {
- return episode.SeriesPresentationUniqueKey;
- }
-
private static string GetUniqueSeriesKey(Series series)
{
return series.GetPresentationUniqueKey();
@@ -178,13 +137,13 @@ namespace Emby.Server.Implementations.TV
{
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
- IncludeItemTypes = new[] { BaseItemKind.Episode },
+ IncludeItemTypes = [BaseItemKind.Episode],
IsPlayed = true,
Limit = 1,
ParentIndexNumberNotEquals = 0,
DtoOptions = new DtoOptions
{
- Fields = new[] { ItemFields.SortName },
+ Fields = [ItemFields.SortName],
EnableImages = false
}
};
@@ -202,8 +161,8 @@ namespace Emby.Server.Implementations.TV
{
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
- IncludeItemTypes = new[] { BaseItemKind.Episode },
- OrderBy = new[] { (ItemSortBy.ParentIndexNumber, SortOrder.Ascending), (ItemSortBy.IndexNumber, SortOrder.Ascending) },
+ IncludeItemTypes = [BaseItemKind.Episode],
+ OrderBy = [(ItemSortBy.ParentIndexNumber, SortOrder.Ascending), (ItemSortBy.IndexNumber, SortOrder.Ascending)],
Limit = 1,
IsPlayed = includePlayed,
IsVirtualItem = false,
@@ -228,7 +187,7 @@ namespace Emby.Server.Implementations.TV
AncestorWithPresentationUniqueKey = null,
SeriesPresentationUniqueKey = seriesKey,
ParentIndexNumber = 0,
- IncludeItemTypes = new[] { BaseItemKind.Episode },
+ IncludeItemTypes = [BaseItemKind.Episode],
IsPlayed = includePlayed,
IsVirtualItem = false,
DtoOptions = dtoOptions
@@ -248,7 +207,7 @@ namespace Emby.Server.Implementations.TV
consideredEpisodes.Add(nextEpisode);
}
- var sortedConsideredEpisodes = _libraryManager.Sort(consideredEpisodes, user, new[] { (ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending) })
+ var sortedConsideredEpisodes = _libraryManager.Sort(consideredEpisodes, user, [(ItemSortBy.AiredEpisodeOrder, SortOrder.Ascending)])
.Cast<Episode>();
if (lastWatchedEpisode is not null)
{
diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs
index df46c2dac..cc070244b 100644
--- a/Jellyfin.Api/Controllers/TvShowsController.cs
+++ b/Jellyfin.Api/Controllers/TvShowsController.cs
@@ -2,6 +2,7 @@ using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
+using Jellyfin.Api.Attributes;
using Jellyfin.Api.Extensions;
using Jellyfin.Api.Helpers;
using Jellyfin.Api.ModelBinders;
@@ -86,7 +87,7 @@ public class TvShowsController : BaseJellyfinApiController
[FromQuery] bool? enableUserData,
[FromQuery] DateTime? nextUpDateCutoff,
[FromQuery] bool enableTotalRecordCount = true,
- [FromQuery] bool disableFirstEpisode = false,
+ [FromQuery][ParameterObsolete] bool disableFirstEpisode = false,
[FromQuery] bool enableResumable = true,
[FromQuery] bool enableRewatching = false)
{
@@ -109,7 +110,6 @@ public class TvShowsController : BaseJellyfinApiController
StartIndex = startIndex,
User = user,
EnableTotalRecordCount = enableTotalRecordCount,
- DisableFirstEpisode = disableFirstEpisode,
NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue,
EnableResumable = enableResumable,
EnableRewatching = enableRewatching
diff --git a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
index 392b7de74..e20ad79ad 100644
--- a/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
+++ b/Jellyfin.Server.Implementations/Item/BaseItemRepository.cs
@@ -255,6 +255,37 @@ public sealed class BaseItemRepository
return dbQuery.AsEnumerable().Where(e => e is not null).Select(w => DeserialiseBaseItem(w, filter.SkipDeserialization)).ToArray();
}
+ /// <inheritdoc />
+ public IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff)
+ {
+ ArgumentNullException.ThrowIfNull(filter);
+ ArgumentNullException.ThrowIfNull(filter.User);
+
+ using var context = _dbProvider.CreateDbContext();
+
+ var query = context.BaseItems
+ .AsNoTracking()
+ .Where(i => filter.TopParentIds.Contains(i.TopParentId!.Value))
+ .Where(i => i.Type == _itemTypeLookup.BaseItemKindNames[BaseItemKind.Episode])
+ .Join(
+ context.UserData.AsNoTracking(),
+ i => new { UserId = filter.User.Id, ItemId = i.Id },
+ u => new { UserId = u.UserId, ItemId = u.ItemId },
+ (entity, data) => new { Item = entity, UserData = data })
+ .GroupBy(g => g.Item.SeriesPresentationUniqueKey)
+ .Select(g => new { g.Key, LastPlayedDate = g.Max(u => u.UserData.LastPlayedDate) })
+ .Where(g => g.Key != null && g.LastPlayedDate != null && g.LastPlayedDate >= dateCutoff)
+ .OrderByDescending(g => g.LastPlayedDate)
+ .Select(g => g.Key!);
+
+ if (filter.Limit.HasValue)
+ {
+ query = query.Take(filter.Limit.Value);
+ }
+
+ return query.ToArray();
+ }
+
private IQueryable<BaseItemEntity> ApplyGroupingFilter(IQueryable<BaseItemEntity> dbQuery, InternalItemsQuery filter)
{
// This whole block is needed to filter duplicate entries on request
diff --git a/MediaBrowser.Controller/Library/ILibraryManager.cs b/MediaBrowser.Controller/Library/ILibraryManager.cs
index 47b1cb16e..03a28fd8c 100644
--- a/MediaBrowser.Controller/Library/ILibraryManager.cs
+++ b/MediaBrowser.Controller/Library/ILibraryManager.cs
@@ -566,6 +566,15 @@ namespace MediaBrowser.Controller.Library
IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery query, List<BaseItem> parents);
/// <summary>
+ /// Gets the list of series presentation keys for next up.
+ /// </summary>
+ /// <param name="query">The query to use.</param>
+ /// <param name="parents">Items to use for query.</param>
+ /// <param name="dateCutoff">The minimum date for a series to have been most recently watched.</param>
+ /// <returns>List of series presentation keys.</returns>
+ IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery query, IReadOnlyCollection<BaseItem> parents, DateTime dateCutoff);
+
+ /// <summary>
/// Gets the items result.
/// </summary>
/// <param name="query">The query.</param>
diff --git a/MediaBrowser.Controller/Persistence/IItemRepository.cs b/MediaBrowser.Controller/Persistence/IItemRepository.cs
index afe2d833d..f1ed4fe27 100644
--- a/MediaBrowser.Controller/Persistence/IItemRepository.cs
+++ b/MediaBrowser.Controller/Persistence/IItemRepository.cs
@@ -60,6 +60,14 @@ public interface IItemRepository
IReadOnlyList<BaseItem> GetItemList(InternalItemsQuery filter);
/// <summary>
+ /// Gets the list of series presentation keys for next up.
+ /// </summary>
+ /// <param name="filter">The query.</param>
+ /// <param name="dateCutoff">The minimum date for a series to have been most recently watched.</param>
+ /// <returns>The list of keys.</returns>
+ IReadOnlyList<string> GetNextUpSeriesKeys(InternalItemsQuery filter, DateTime dateCutoff);
+
+ /// <summary>
/// Updates the inherited values.
/// </summary>
void UpdateInheritedValues();
diff --git a/MediaBrowser.Model/Querying/NextUpQuery.cs b/MediaBrowser.Model/Querying/NextUpQuery.cs
index 8dece28a0..aee720aa7 100644
--- a/MediaBrowser.Model/Querying/NextUpQuery.cs
+++ b/MediaBrowser.Model/Querying/NextUpQuery.cs
@@ -4,76 +4,69 @@ using System;
using Jellyfin.Data.Entities;
using MediaBrowser.Model.Entities;
-namespace MediaBrowser.Model.Querying
+namespace MediaBrowser.Model.Querying;
+
+public class NextUpQuery
{
- public class NextUpQuery
+ public NextUpQuery()
{
- public NextUpQuery()
- {
- EnableImageTypes = Array.Empty<ImageType>();
- EnableTotalRecordCount = true;
- DisableFirstEpisode = false;
- NextUpDateCutoff = DateTime.MinValue;
- EnableResumable = false;
- EnableRewatching = false;
- }
-
- /// <summary>
- /// Gets or sets the user.
- /// </summary>
- /// <value>The user.</value>
- public required User User { get; set; }
+ EnableImageTypes = Array.Empty<ImageType>();
+ EnableTotalRecordCount = true;
+ NextUpDateCutoff = DateTime.MinValue;
+ EnableResumable = false;
+ EnableRewatching = false;
+ }
- /// <summary>
- /// Gets or sets the parent identifier.
- /// </summary>
- /// <value>The parent identifier.</value>
- public Guid? ParentId { get; set; }
+ /// <summary>
+ /// Gets or sets the user.
+ /// </summary>
+ /// <value>The user.</value>
+ public required User User { get; set; }
- /// <summary>
- /// Gets or sets the series id.
- /// </summary>
- /// <value>The series id.</value>
- public Guid? SeriesId { get; set; }
+ /// <summary>
+ /// Gets or sets the parent identifier.
+ /// </summary>
+ /// <value>The parent identifier.</value>
+ public Guid? ParentId { get; set; }
- /// <summary>
- /// Gets or sets the start index. Use for paging.
- /// </summary>
- /// <value>The start index.</value>
- public int? StartIndex { get; set; }
+ /// <summary>
+ /// Gets or sets the series id.
+ /// </summary>
+ /// <value>The series id.</value>
+ public Guid? SeriesId { get; set; }
- /// <summary>
- /// Gets or sets the maximum number of items to return.
- /// </summary>
- /// <value>The limit.</value>
- public int? Limit { get; set; }
+ /// <summary>
+ /// Gets or sets the start index. Use for paging.
+ /// </summary>
+ /// <value>The start index.</value>
+ public int? StartIndex { get; set; }
- /// <summary>
- /// Gets or sets the enable image types.
- /// </summary>
- /// <value>The enable image types.</value>
- public ImageType[] EnableImageTypes { get; set; }
+ /// <summary>
+ /// Gets or sets the maximum number of items to return.
+ /// </summary>
+ /// <value>The limit.</value>
+ public int? Limit { get; set; }
- public bool EnableTotalRecordCount { get; set; }
+ /// <summary>
+ /// Gets or sets the enable image types.
+ /// </summary>
+ /// <value>The enable image types.</value>
+ public ImageType[] EnableImageTypes { get; set; }
- /// <summary>
- /// Gets or sets a value indicating whether do disable sending first episode as next up.
- /// </summary>
- public bool DisableFirstEpisode { get; set; }
+ public bool EnableTotalRecordCount { get; set; }
- /// <summary>
- /// Gets or sets a value indicating the oldest date for a show to appear in Next Up.
- /// </summary>
- public DateTime NextUpDateCutoff { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating the oldest date for a show to appear in Next Up.
+ /// </summary>
+ public DateTime NextUpDateCutoff { get; set; }
- /// <summary>
- /// Gets or sets a value indicating whether to include resumable episodes as next up.
- /// </summary>
- public bool EnableResumable { get; set; }
+ /// <summary>
+ /// Gets or sets a value indicating whether to include resumable episodes as next up.
+ /// </summary>
+ public bool EnableResumable { get; set; }
- /// <summary>
- /// Gets or sets a value indicating whether getting rewatching next up list.
- /// </summary>
- public bool EnableRewatching { get; set; }
- }
+ /// <summary>
+ /// Gets or sets a value indicating whether getting rewatching next up list.
+ /// </summary>
+ public bool EnableRewatching { get; set; }
}