aboutsummaryrefslogtreecommitdiff
path: root/Emby.Server.Implementations
diff options
context:
space:
mode:
Diffstat (limited to 'Emby.Server.Implementations')
-rw-r--r--Emby.Server.Implementations/Dto/DtoService.cs33
-rw-r--r--Emby.Server.Implementations/HttpServer/WebSocketConnection.cs6
-rw-r--r--Emby.Server.Implementations/IO/ManagedFileSystem.cs2
-rw-r--r--Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs42
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs69
-rw-r--r--Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs12
-rw-r--r--Emby.Server.Implementations/Library/UserDataManager.cs137
-rw-r--r--Emby.Server.Implementations/Localization/Core/da.json12
-rw-r--r--Emby.Server.Implementations/Localization/Core/si.json16
-rw-r--r--Emby.Server.Implementations/Localization/Core/sq.json4
-rw-r--r--Emby.Server.Implementations/Localization/Core/zh-HK.json4
-rw-r--r--Emby.Server.Implementations/Localization/LocalizationManager.cs21
-rw-r--r--Emby.Server.Implementations/Localization/Ratings/se.json13
-rw-r--r--Emby.Server.Implementations/Session/SessionManager.cs56
-rw-r--r--Emby.Server.Implementations/Session/SessionWebSocketListener.cs15
-rw-r--r--Emby.Server.Implementations/TV/TVSeriesManager.cs92
16 files changed, 461 insertions, 73 deletions
diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs
index 3cd72a8ac1..5c76c4014a 100644
--- a/Emby.Server.Implementations/Dto/DtoService.cs
+++ b/Emby.Server.Implementations/Dto/DtoService.cs
@@ -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;
diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
index e9bf3b93a7..dc7f972c13 100644
--- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
+++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs
@@ -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;
}
diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
index 199407044b..ede9b27592 100644
--- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs
+++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs
@@ -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>();
}
}
diff --git a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
index 095934f896..b701e7eb6d 100644
--- a/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
+++ b/Emby.Server.Implementations/Images/CollectionFolderImageProvider.cs
@@ -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
});
}
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index c369fb0957..c64833ddaa 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -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;
diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
index 14798dda65..74c1f69616 100644
--- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
+++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs
@@ -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
diff --git a/Emby.Server.Implementations/Library/UserDataManager.cs b/Emby.Server.Implementations/Library/UserDataManager.cs
index 1281f1587f..40cd2bb69c 100644
--- a/Emby.Server.Implementations/Library/UserDataManager.cs
+++ b/Emby.Server.Implementations/Library/UserDataManager.cs
@@ -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();
+ }
}
}
diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json
index 697d9c090f..de56b6fd66 100644
--- a/Emby.Server.Implementations/Localization/Core/da.json
+++ b/Emby.Server.Implementations/Localization/Core/da.json
@@ -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",
diff --git a/Emby.Server.Implementations/Localization/Core/si.json b/Emby.Server.Implementations/Localization/Core/si.json
index 0967ef424b..8efc0d1f2e 100644
--- a/Emby.Server.Implementations/Localization/Core/si.json
+++ b/Emby.Server.Implementations/Localization/Core/si.json
@@ -1 +1,15 @@
-{}
+{
+ "AppDeviceValues": "ඇප්: {0}, උපාංග: {1}",
+ "Artists": "කලාකරුවන්",
+ "AuthenticationSucceededWithUserName": "{0} සාර්ථකව තහවුරු කරන ලදී",
+ "Books": "පොත්",
+ "ChapterNameValue": "{0} වෙනි පරිච්ඡේදය",
+ "Collections": "සංහිතා",
+ "Default": "පෙරනිමි",
+ "External": "බාහිර",
+ "FailedLoginAttemptWithUserName": "{0} වෙතින් සිදුකළ පිවිසීමේ උත්සාහය අසාර්ථක විය",
+ "Favorites": "ප්‍රියතමයන්",
+ "Folders": "ෆෝල්ඩර",
+ "Forced": "නියමිත",
+ "Genres": "ප්‍රභේද"
+}
diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json
index b1f76aafbb..e13f7b09e1 100644
--- a/Emby.Server.Implementations/Localization/Core/sq.json
+++ b/Emby.Server.Implementations/Localization/Core/sq.json
@@ -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"
}
diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json
index 1098880cf3..b2fcbc93a2 100644
--- a/Emby.Server.Implementations/Localization/Core/zh-HK.json
+++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json
@@ -77,8 +77,8 @@
"TaskCleanLogsDescription": "自動刪走超過 {0} 日嘅紀錄檔。",
"TaskCleanLogs": "清理日誌資料夾",
"TaskRefreshLibrary": "掃描媒體櫃",
- "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節縮圖。",
- "TaskRefreshChapterImages": "擷取章節圖片",
+ "TaskRefreshChapterImagesDescription": "幫有章節嘅影片整返啲章節預覽圖。",
+ "TaskRefreshChapterImages": "擷取章節圖像",
"TaskCleanCacheDescription": "刪走系統已經唔再需要嘅快取檔案。",
"TaskCleanCache": "清理快取(Cache)資料夾",
"TasksChannelsCategory": "網路頻道",
diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs
index 6971431155..98f629d31c 100644
--- a/Emby.Server.Implementations/Localization/LocalizationManager.cs
+++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs
@@ -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))
{
diff --git a/Emby.Server.Implementations/Localization/Ratings/se.json b/Emby.Server.Implementations/Localization/Ratings/se.json
index 70084995d1..818565e16b 100644
--- a/Emby.Server.Implementations/Localization/Ratings/se.json
+++ b/Emby.Server.Implementations/Localization/Ratings/se.json
@@ -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
+ }
}
]
}
diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs
index 19823dff37..828bdd6859 100644
--- a/Emby.Server.Implementations/Session/SessionManager.cs
+++ b/Emby.Server.Implementations/Session/SessionManager.cs
@@ -730,6 +730,31 @@ namespace Emby.Server.Implementations.Session
}
/// <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>
/// <param name="info">The info.</param>
@@ -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;
}
diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
index 6a26e92e14..2582ed9df0 100644
--- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
+++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs
@@ -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);
+ }
}
}
}
diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs
index 535dc01a31..459ad1a17e 100644
--- a/Emby.Server.Implementations/TV/TVSeriesManager.cs
+++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs
@@ -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();