aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Emby.Server.Implementations/Library/LibraryManager.cs10
-rw-r--r--Emby.Server.Implementations/Library/MediaSourceManager.cs44
-rw-r--r--Emby.Server.Implementations/Localization/Core/sr.json4
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs3
-rw-r--r--Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs1
-rw-r--r--Jellyfin.Api/Controllers/ItemsController.cs1
-rw-r--r--Jellyfin.Api/Controllers/MediaInfoController.cs2
-rw-r--r--Jellyfin.Api/Controllers/UniversalAudioController.cs2
-rw-r--r--Jellyfin.Api/Helpers/MediaInfoHelper.cs13
-rw-r--r--Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs4
-rw-r--r--Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs29
-rw-r--r--MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs3
-rw-r--r--src/Jellyfin.LiveTv/Guide/GuideManager.cs9
-rw-r--r--src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs1
-rw-r--r--tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs99
15 files changed, 198 insertions, 27 deletions
diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs
index 6ed417c395..3691f4e19d 100644
--- a/Emby.Server.Implementations/Library/LibraryManager.cs
+++ b/Emby.Server.Implementations/Library/LibraryManager.cs
@@ -2450,8 +2450,14 @@ namespace Emby.Server.Implementations.Library
var outdated = forceUpdate
? item.ImageInfos.Where(i => i.Path is not null).ToArray()
: item.ImageInfos.Where(ImageNeedsRefresh).ToArray();
- // Skip image processing if current or live tv source
- if (outdated.Length == 0 || item.SourceType != SourceType.Library)
+
+ var parentItem = item.GetParent();
+ var isLiveTvShow = item.SourceType != SourceType.Library &&
+ parentItem is not null &&
+ parentItem.SourceType != SourceType.Library; // not a channel
+
+ // Skip image processing if current or live tv show
+ if (outdated.Length == 0 || isLiveTvShow)
{
RegisterItem(item);
return;
diff --git a/Emby.Server.Implementations/Library/MediaSourceManager.cs b/Emby.Server.Implementations/Library/MediaSourceManager.cs
index 9ccfefa86e..c369fb0957 100644
--- a/Emby.Server.Implementations/Library/MediaSourceManager.cs
+++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs
@@ -229,7 +229,7 @@ namespace Emby.Server.Implementations.Library
list.Add(source);
}
- return SortMediaSources(list).ToArray();
+ return SortMediaSources(list, item.Id).ToArray();
}
/// <inheritdoc />>
@@ -386,6 +386,12 @@ namespace Emby.Server.Implementations.Library
if (user is not null)
{
+ sources = sources
+ .Where(source => !Guid.TryParse(source.Id, out var sourceId)
+ || sourceId.Equals(item.Id)
+ || _libraryManager.GetItemById<BaseItem>(sourceId, user) is not null)
+ .ToArray();
+
foreach (var source in sources)
{
SetDefaultAudioAndSubtitleStreamIndices(item, source, user);
@@ -540,24 +546,32 @@ namespace Emby.Server.Implementations.Library
}
}
- private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources)
+ private static IEnumerable<MediaSourceInfo> SortMediaSources(IEnumerable<MediaSourceInfo> sources, Guid preferredItemId = default)
{
- return sources.OrderBy(i =>
- {
- if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
+ // The source belonging to the queried item sorts first so it stays the default that gets played.
+ var preferredId = preferredItemId.IsEmpty()
+ ? null
+ : preferredItemId.ToString("N", CultureInfo.InvariantCulture);
+
+ return sources
+ .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(i =>
{
- return 0;
- }
+ if (i.VideoType.HasValue && i.VideoType.Value == VideoType.VideoFile)
+ {
+ return 0;
+ }
- return 1;
- }).ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
- .ThenByDescending(i =>
- {
- var stream = i.VideoStream;
+ return 1;
+ })
+ .ThenBy(i => i.Video3DFormat.HasValue ? 1 : 0)
+ .ThenByDescending(i =>
+ {
+ var stream = i.VideoStream;
- return stream?.Width ?? 0;
- })
- .Where(i => i.Type != MediaSourceType.Placeholder);
+ return stream?.Width ?? 0;
+ })
+ .Where(i => i.Type != MediaSourceType.Placeholder);
}
public async Task<Tuple<LiveStreamResponse, IDirectStreamProvider>> OpenLiveStreamInternal(LiveStreamRequest request, CancellationToken cancellationToken)
diff --git a/Emby.Server.Implementations/Localization/Core/sr.json b/Emby.Server.Implementations/Localization/Core/sr.json
index 56806e25c1..92f309c80c 100644
--- a/Emby.Server.Implementations/Localization/Core/sr.json
+++ b/Emby.Server.Implementations/Localization/Core/sr.json
@@ -106,5 +106,7 @@
"CleanupUserDataTask": "Задатак чишћења корисничких података",
"CleanupUserDataTaskDescription": "Чисти све корисничке податке (напредак гледања, ознаке за омиљено...) медија који нису доступни 90 дана или дуже.",
"TaskMoveTrickplayImages": "Промени локацију сличица за визуелно премотавање",
- "TaskDownloadMissingLyricsDescription": "Преузми стихове песама"
+ "TaskDownloadMissingLyricsDescription": "Преузми стихове песама",
+ "LyricDownloadFailureFromForItem": "Није успело преузимање стихова са {0} за {1}",
+ "Original": "Изворно"
}
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
index f81309560e..f1e1579a1d 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs
@@ -92,7 +92,8 @@ public class ChapterImagesTask : IScheduledTask
EnableImages = false
},
SourceTypes = [SourceType.Library],
- IsVirtualItem = false
+ IsVirtualItem = false,
+ IncludeOwnedItems = true
})
.OfType<Video>()
.ToList();
diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
index 5e92808f78..9cc6eb265a 100644
--- a/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
+++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/MediaSegmentExtractionTask.cs
@@ -68,6 +68,7 @@ public class MediaSegmentExtractionTask : IScheduledTask
DtoOptions = new DtoOptions(true),
SourceTypes = [SourceType.Library],
Recursive = true,
+ IncludeOwnedItems = true,
Limit = pagesize
};
diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs
index 9115227707..5f23f2fcee 100644
--- a/Jellyfin.Api/Controllers/ItemsController.cs
+++ b/Jellyfin.Api/Controllers/ItemsController.cs
@@ -981,6 +981,7 @@ public class ItemsController : BaseJellyfinApiController
MediaTypes = mediaTypes,
IsVirtualItem = false,
CollapseBoxSetItems = false,
+ IncludeOwnedItems = true,
EnableTotalRecordCount = enableTotalRecordCount,
AncestorIds = ancestorIds,
IncludeItemTypes = includeItemTypes,
diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs
index f22ac0b73a..ac7c091f85 100644
--- a/Jellyfin.Api/Controllers/MediaInfoController.cs
+++ b/Jellyfin.Api/Controllers/MediaInfoController.cs
@@ -213,7 +213,7 @@ public class MediaInfoController : BaseJellyfinApiController
Request.HttpContext.GetNormalizedRemoteIP());
}
- _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
+ _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id);
}
if (autoOpenLiveStream.Value)
diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs
index 2f5ed327c0..e53d15acfd 100644
--- a/Jellyfin.Api/Controllers/UniversalAudioController.cs
+++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs
@@ -163,7 +163,7 @@ public class UniversalAudioController : BaseJellyfinApiController
Request.HttpContext.GetNormalizedRemoteIP());
}
- _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate);
+ _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate, item.Id);
foreach (var source in info.MediaSources)
{
diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs
index 454d3f08e3..ef81235808 100644
--- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs
+++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs
@@ -351,11 +351,20 @@ public class MediaInfoHelper
/// </summary>
/// <param name="result">Playback info response.</param>
/// <param name="maxBitrate">Max bitrate.</param>
- public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate)
+ /// <param name="preferredItemId">The id of the queried item, whose own media source must stay the default.</param>
+ public void SortMediaSources(PlaybackInfoResponse result, long? maxBitrate, Guid preferredItemId = default)
{
var originalList = result.MediaSources.ToList();
- result.MediaSources = result.MediaSources.OrderBy(i =>
+ // The queried item's source carries the user's resume state for that version, so it must stay the
+ // default the client plays. An unfavorable bitrate means transcoding it, not switching to a sibling version.
+ var preferredId = preferredItemId.IsEmpty()
+ ? null
+ : preferredItemId.ToString("N", CultureInfo.InvariantCulture);
+
+ result.MediaSources = result.MediaSources
+ .OrderByDescending(i => preferredId is not null && string.Equals(i.Id, preferredId, StringComparison.OrdinalIgnoreCase))
+ .ThenBy(i =>
{
// Nothing beats direct playing a file
if (i.SupportsDirectPlay && i.Protocol == MediaProtocol.File)
diff --git a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
index ffa5cff1f2..7c0cfe7c15 100644
--- a/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
+++ b/Jellyfin.Server.Implementations/Item/ItemPersistenceService.cs
@@ -557,9 +557,11 @@ public class ItemPersistenceService : IItemPersistenceService
}
}
+ // Deduplicate; local (file-based) relationships take priority over linked (user-merged)
+ // ones, matching the LinkedChildren migration.
newLinkedChildren = newLinkedChildren
.GroupBy(c => c.ChildId)
- .Select(g => g.Last())
+ .Select(g => g.OrderBy(c => c.Type == LinkedChildType.LocalAlternateVersion ? 0 : 1).First())
.ToList();
var childIdsToCheck = newLinkedChildren.Select(c => c.ChildId).ToList();
diff --git a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
index 74f03f5107..c433c1d043 100644
--- a/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
+++ b/Jellyfin.Server/Migrations/Routines/20260113120000_MigrateLinkedChildren.cs
@@ -223,6 +223,35 @@ internal class MigrateLinkedChildren : IDatabaseMigrationRoutine
toInsert = toInsert.Where(lc => existingChildIds.Contains(lc.ChildId)).ToList();
+ // Drop linked (user-merged) entries that point at items the parent owns (local
+ // file-based alternates or extras). These stem from legacy data that merged an
+ // owned item onto its own primary and would wrongly mark server-merged groups
+ // as user-merged (splittable).
+ var linkedChildIds = toInsert
+ .Where(lc => lc.ChildType == LinkedChildType.LinkedAlternateVersion)
+ .Select(lc => lc.ChildId)
+ .Distinct()
+ .ToList();
+
+ if (linkedChildIds.Count > 0)
+ {
+ var ownerIdByChildId = context.BaseItems
+ .WhereOneOrMany(linkedChildIds, b => b.Id)
+ .Where(b => b.OwnerId.HasValue)
+ .Select(b => new { b.Id, b.OwnerId })
+ .ToDictionary(b => b.Id, b => b.OwnerId!.Value);
+
+ var removedCount = toInsert.RemoveAll(lc =>
+ lc.ChildType == LinkedChildType.LinkedAlternateVersion
+ && ownerIdByChildId.TryGetValue(lc.ChildId, out var ownerId)
+ && ownerId.Equals(lc.ParentId));
+
+ if (removedCount > 0)
+ {
+ _logger.LogInformation("Skipped {Count} LinkedAlternateVersion records pointing at items owned by their parent.", removedCount);
+ }
+ }
+
context.LinkedChildren.AddRange(toInsert);
context.SaveChanges();
diff --git a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs
index f1582febf2..1d3a273354 100644
--- a/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs
+++ b/MediaBrowser.Providers/MediaInfo/SubtitleScheduledTask.cs
@@ -102,7 +102,8 @@ namespace MediaBrowser.Providers.MediaInfo
DtoOptions = new DtoOptions(true),
SourceTypes = new[] { SourceType.Library },
Parent = library,
- Recursive = true
+ Recursive = true,
+ IncludeOwnedItems = true
};
if (skipIfAudioTrackMatches)
diff --git a/src/Jellyfin.LiveTv/Guide/GuideManager.cs b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
index 556516674b..c3cc70381e 100644
--- a/src/Jellyfin.LiveTv/Guide/GuideManager.cs
+++ b/src/Jellyfin.LiveTv/Guide/GuideManager.cs
@@ -448,14 +448,19 @@ public class GuideManager : IGuideManager
item.Name = channelInfo.Name;
- if (!item.HasImage(ImageType.Primary))
+ var currentPrimary = item.GetImageInfo(ImageType.Primary, 0);
+ var imageUrlIsNull = string.IsNullOrWhiteSpace(channelInfo.ImageUrl);
+
+ // Update channel image if image URL has changed
+ if (currentPrimary is null
+ || (!imageUrlIsNull && !string.Equals(currentPrimary.Path, channelInfo.ImageUrl, StringComparison.Ordinal)))
{
if (!string.IsNullOrWhiteSpace(channelInfo.ImagePath))
{
item.SetImagePath(ImageType.Primary, channelInfo.ImagePath);
forceUpdate = true;
}
- else if (!string.IsNullOrWhiteSpace(channelInfo.ImageUrl))
+ else if (!imageUrlIsNull)
{
item.SetImagePath(ImageType.Primary, channelInfo.ImageUrl);
forceUpdate = true;
diff --git a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs
index fcf37f35d7..6d3ae56f56 100644
--- a/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs
+++ b/src/Jellyfin.MediaEncoding.Hls/ScheduledTasks/KeyframeExtractionScheduledTask.cs
@@ -60,6 +60,7 @@ public class KeyframeExtractionScheduledTask : IScheduledTask
DtoOptions = new DtoOptions(true),
SourceTypes = [SourceType.Library],
Recursive = true,
+ IncludeOwnedItems = true,
Limit = Pagesize
};
diff --git a/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
new file mode 100644
index 0000000000..a003be4d96
--- /dev/null
+++ b/tests/Jellyfin.Api.Tests/Helpers/MediaInfoHelperTests.cs
@@ -0,0 +1,99 @@
+using System;
+using System.Globalization;
+using Jellyfin.Api.Helpers;
+using MediaBrowser.Common.Net;
+using MediaBrowser.Controller.Configuration;
+using MediaBrowser.Controller.Devices;
+using MediaBrowser.Controller.Library;
+using MediaBrowser.Controller.MediaEncoding;
+using MediaBrowser.Model.Dto;
+using MediaBrowser.Model.MediaInfo;
+using Microsoft.Extensions.Logging;
+using Moq;
+using Xunit;
+
+namespace Jellyfin.Api.Tests.Helpers
+{
+ public class MediaInfoHelperTests
+ {
+ private static MediaInfoHelper CreateHelper()
+ {
+ return new MediaInfoHelper(
+ Mock.Of<IUserManager>(),
+ Mock.Of<ILibraryManager>(),
+ Mock.Of<IMediaSourceManager>(),
+ Mock.Of<IMediaEncoder>(),
+ Mock.Of<IServerConfigurationManager>(),
+ Mock.Of<ILogger<MediaInfoHelper>>(),
+ Mock.Of<INetworkManager>(),
+ Mock.Of<IDeviceManager>());
+ }
+
+ private static MediaSourceInfo CreateSource(Guid itemId, int bitrate, bool supportsDirectPlay = true)
+ {
+ return new MediaSourceInfo
+ {
+ Id = itemId.ToString("N", CultureInfo.InvariantCulture),
+ Protocol = MediaProtocol.File,
+ Bitrate = bitrate,
+ SupportsDirectPlay = supportsDirectPlay,
+ SupportsDirectStream = true,
+ SupportsTranscoding = true
+ };
+ }
+
+ [Fact]
+ public void SortMediaSources_PreferredItemExceedsBitrate_StaysDefault()
+ {
+ // The version the user was watching (the queried item) must stay the default
+ // even when a sibling version fits the bitrate limit better, since the resume
+ // position belongs to that exact version.
+ var preferredItemId = Guid.NewGuid();
+ var preferredSource = CreateSource(preferredItemId, bitrate: 80_000_000, supportsDirectPlay: false);
+ var siblingSource = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
+
+ var result = new PlaybackInfoResponse
+ {
+ MediaSources = [siblingSource, preferredSource]
+ };
+
+ CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, preferredItemId);
+
+ Assert.Equal(preferredSource.Id, result.MediaSources[0].Id);
+ }
+
+ [Fact]
+ public void SortMediaSources_NoPreferredItem_OrdersByPlayability()
+ {
+ var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
+ var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false);
+ transcodeOnly.SupportsDirectStream = false;
+
+ var result = new PlaybackInfoResponse
+ {
+ MediaSources = [transcodeOnly, directPlay]
+ };
+
+ CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000);
+
+ Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
+ }
+
+ [Fact]
+ public void SortMediaSources_PreferredIdNotInSources_KeepsPlayabilityOrder()
+ {
+ var directPlay = CreateSource(Guid.NewGuid(), bitrate: 8_000_000);
+ var transcodeOnly = CreateSource(Guid.NewGuid(), bitrate: 8_000_000, supportsDirectPlay: false);
+ transcodeOnly.SupportsDirectStream = false;
+
+ var result = new PlaybackInfoResponse
+ {
+ MediaSources = [transcodeOnly, directPlay]
+ };
+
+ CreateHelper().SortMediaSources(result, maxBitrate: 20_000_000, Guid.NewGuid());
+
+ Assert.Equal(directPlay.Id, result.MediaSources[0].Id);
+ }
+ }
+}